@twip/web 0.0.0-canary-4d6177a → 0.0.0-canary-abcb2d5

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.
@@ -680,6 +680,8 @@ export interface Commands {
680
680
  showAutoCorrectOptions(): Promise<void>;
681
681
  /** Dismisses the popover asked for (`popoverClosed`), for an application that closes its own on Esc or a press elsewhere; with none open, nothing. View. */
682
682
  closePopover(): Promise<void>;
683
+ /** Says the formatting at the caret to screen readers (an `announcement` of kind `formatting`). Shift+F1. */
684
+ describeFormatting(): Promise<void>;
683
685
  }
684
686
  /** A command id: the method name, the batch op's `type`, the catalog id and the key in `state.commands`. */
685
687
  export type CommandId = keyof Commands;
package/dist/commands.js CHANGED
@@ -191,6 +191,7 @@ const encodings = {
191
191
  neverAutoCorrect: 'none',
192
192
  showAutoCorrectOptions: 'none',
193
193
  closePopover: 'none',
194
+ describeFormatting: 'none',
194
195
  };
195
196
  /** Builds a command's op from its method arguments. */
196
197
  export function toOp(id, arg) {
package/dist/editor.d.ts CHANGED
@@ -74,6 +74,8 @@ export interface MountOptions extends SessionOptions {
74
74
  * `leaders` colour, so the application draws only the cards. Default false: the application draws them.
75
75
  */
76
76
  drawLeaders?: boolean;
77
+ /** The editor's accessible name, which screen readers say when it gets the focus. Default `'Document'`. */
78
+ label?: string;
77
79
  /** Aborts the mount; everything it created is released. */
78
80
  signal?: AbortSignal;
79
81
  }
package/dist/events.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { EditorError } from './errors.js';
2
2
  import type { BalloonPlacement, CommentAnchor } from './review.js';
3
+ import type { AnnouncementKind } from './services/accessibility.js';
3
4
  import type { DialogKind } from './services/dialogs.js';
4
5
  import type { ElementInfo } from './services/elements.js';
5
6
  import type { ContextMenuEvent } from './services/menus.js';
@@ -101,9 +102,10 @@ export interface TwipEvents {
101
102
  rulerChanged: RulerView;
102
103
  /** Mounted only: hover over a link, element mark, revision or comment, or `null` when it ends. */
103
104
  tooltip: Tooltip | null;
104
- /** What the editor said to screen readers. */
105
+ /** What the editor said to screen readers, and what it is about (when one change says several things, the first). */
105
106
  announcement: {
106
107
  text: string;
108
+ kind: AnnouncementKind;
107
109
  };
108
110
  /** A fidelity warning outside an operation. */
109
111
  warning: Warning;
@@ -1,3 +1,9 @@
1
1
  import type { Session } from '../editor.js';
2
2
  import type { Dom, Ledger } from './dom.js';
3
- export declare function startA11y(s: Session, dom: Dom, ledger: Ledger): void;
3
+ /** The mirror's part in pointer input. */
4
+ export interface A11y {
5
+ /** Whether a mirror node was just activated: a press now is the screen reader's click, and places no caret. */
6
+ activating(): boolean;
7
+ }
8
+ /** Mirrors the tree and speaks announcements. `label` is the editor's accessible name; `focus` focuses the editing element. */
9
+ export declare function startA11y(s: Session, dom: Dom, ledger: Ledger, label: string, focus: () => void, report: (e: unknown) => void): A11y;
package/dist/host/a11y.js CHANGED
@@ -1,42 +1,156 @@
1
- import { Kind } from '../transport/protocol.js';
2
- const roles = {
3
- document: 'document',
4
- page: 'region',
5
- paragraph: 'paragraph',
6
- heading: 'heading',
7
- list: 'list',
8
- listItem: 'listitem',
9
- table: 'table',
10
- row: 'row',
11
- cell: 'cell',
12
- image: 'img',
13
- link: 'link',
14
- textBox: 'group',
15
- note: 'note',
16
- header: 'region',
17
- footer: 'region',
1
+ import { Kind, Method } from '../transport/protocol.js';
2
+ /** Each role's element and ARIA role. Inline nodes are spans; the rest are divs. */
3
+ const elements = {
4
+ document: { tag: 'div', role: 'document' },
5
+ paragraph: { tag: 'div', role: 'paragraph' },
6
+ heading: { tag: 'div', role: 'heading' },
7
+ list: { tag: 'div', role: 'list' },
8
+ listItem: { tag: 'div', role: 'listitem' },
9
+ table: { tag: 'div', role: 'table' },
10
+ row: { tag: 'div', role: 'row' },
11
+ cell: { tag: 'div', role: 'cell' },
12
+ columnHeader: { tag: 'div', role: 'columnheader' },
13
+ rowHeader: { tag: 'div', role: 'rowheader' },
14
+ image: { tag: 'span', role: 'img' },
15
+ link: { tag: 'span', role: 'link' },
16
+ textBox: { tag: 'div', role: 'group' },
17
+ note: { tag: 'div', role: 'note' },
18
+ noteRef: { tag: 'span', role: 'link' },
19
+ comment: { tag: 'div', role: 'comment' },
20
+ // Named groups inside the headers and footers region: the landmark key goes from region to region, not into each.
21
+ header: { tag: 'div', role: 'group' },
22
+ footer: { tag: 'div', role: 'group' },
23
+ region: { tag: 'div', role: 'region' },
24
+ text: { tag: 'span' },
18
25
  };
19
- const hidden = 'position:absolute;left:0;top:0;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;';
20
- function build(node) {
21
- const e = document.createElement('div');
22
- e.setAttribute('role', roles[node.role] ?? 'group');
23
- if (node.name)
24
- e.setAttribute('aria-label', node.name);
25
- if (node.level !== undefined)
26
- e.setAttribute('aria-level', String(node.level));
27
- const marks = node.marks ?? [];
28
- if (marks.includes('misspelling'))
29
- e.setAttribute('aria-invalid', 'spelling');
30
- const review = marks.filter((m) => m !== 'misspelling');
31
- if (review.length)
32
- e.setAttribute('aria-description', review.join(' '));
33
- if (node.text)
34
- e.append(node.text);
35
- for (const child of node.children)
36
- e.append(build(child));
37
- return e;
26
+ /** Nodes that hold no caret position of their own: the rest take the focus, and focusing one starts editing there. */
27
+ const unfocused = new Set(['document', 'region', 'header', 'footer', 'list', 'table', 'row', 'text']);
28
+ // Out of sight, one line per block: wrapped at 1px, a screen reader using the screen's layout reads a word a line.
29
+ const hidden = 'position:absolute;left:0;top:0;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:pre;';
30
+ /** Mounts on one page get distinct element ids: `aria-details` refers to them. */
31
+ let mounts = 0;
32
+ /** The mirror of one mount's tree. */
33
+ class Mirror {
34
+ #byId = new Map();
35
+ #prefix = `twip-${++mounts}-`;
36
+ root;
37
+ label;
38
+ constructor(root, label) {
39
+ this.root = root;
40
+ this.label = label;
41
+ }
42
+ /** The element id of node `id`, for `aria-details`. */
43
+ #elementId(id) {
44
+ return this.#prefix + id;
45
+ }
46
+ #build(node) {
47
+ const { tag, role } = elements[node.role] ?? elements.paragraph;
48
+ // Insertions and deletions are ins and del, which screen readers report as such.
49
+ const marks = node.marks ?? [];
50
+ const e = document.createElement(marks.includes('insertion') ? 'ins' : marks.includes('deletion') ? 'del' : tag);
51
+ e.dataset.twipId = String(node.id);
52
+ e.id = this.#elementId(node.id);
53
+ if (role)
54
+ e.setAttribute('role', role);
55
+ const name = node.role === 'document' ? this.label : node.name;
56
+ // A note reference holds no text of its own: its name is its text (a name alone, Chrome's NVDA reads as a letter).
57
+ if (name && node.role === 'noteRef' && !node.text && !node.children.length)
58
+ e.append(name);
59
+ else if (name && node.role !== 'text')
60
+ e.setAttribute('aria-label', name);
61
+ if (node.level !== undefined && (node.role === 'heading' || node.role === 'listItem'))
62
+ e.setAttribute('aria-level', String(node.level));
63
+ if (node.position) {
64
+ e.setAttribute('aria-posinset', String(node.position.index));
65
+ e.setAttribute('aria-setsize', String(node.position.count));
66
+ }
67
+ if (node.cell) {
68
+ e.setAttribute('aria-colindex', String(node.cell.col));
69
+ if (node.cell.colSpan > 1)
70
+ e.setAttribute('aria-colspan', String(node.cell.colSpan));
71
+ if (node.cell.rowSpan > 1)
72
+ e.setAttribute('aria-rowspan', String(node.cell.rowSpan));
73
+ }
74
+ if (marks.includes('misspelling'))
75
+ e.setAttribute('aria-invalid', 'spelling');
76
+ if (node.author && (marks.includes('insertion') || marks.includes('deletion')))
77
+ e.setAttribute('aria-description', `by ${node.author}`);
78
+ if (node.lang)
79
+ e.lang = node.lang;
80
+ if (node.details !== undefined)
81
+ e.setAttribute('aria-details', this.#elementId(node.details));
82
+ // Focusable, as a screen reader's activation (browse mode Enter) focuses the element before it clicks.
83
+ if (!unfocused.has(node.role))
84
+ e.tabIndex = -1;
85
+ if (node.label)
86
+ e.append(Object.assign(document.createElement('span'), { textContent: `${node.label} ` }));
87
+ if (node.text)
88
+ e.append(node.text);
89
+ for (const child of node.children)
90
+ e.append(this.#build(child));
91
+ // A row's index: its cells'.
92
+ if (node.role === 'row') {
93
+ const row = node.children.find((c) => c.cell)?.cell?.row;
94
+ if (row !== undefined)
95
+ e.setAttribute('aria-rowindex', String(row));
96
+ }
97
+ this.#byId.set(node.id, e);
98
+ return e;
99
+ }
100
+ #forget(e) {
101
+ for (const x of [e, ...e.querySelectorAll('[data-twip-id]')]) {
102
+ const id = Number(x.dataset.twipId);
103
+ if (this.#byId.get(id) === x)
104
+ this.#byId.delete(id);
105
+ }
106
+ }
107
+ update(update) {
108
+ if ('full' in update) {
109
+ this.#byId.clear();
110
+ this.root.replaceChildren(this.#build(update.full));
111
+ return;
112
+ }
113
+ // Replaced nodes not in the tree yet wait for the `children` change that places them.
114
+ const detached = new Map();
115
+ for (const change of update.patch) {
116
+ if (change.type === 'replace') {
117
+ const old = this.#byId.get(change.node.id);
118
+ if (old)
119
+ this.#forget(old);
120
+ const e = this.#build(change.node);
121
+ if (old)
122
+ old.replaceWith(e);
123
+ else
124
+ detached.set(change.node.id, e);
125
+ continue;
126
+ }
127
+ const parent = this.#byId.get(change.parent);
128
+ if (!parent)
129
+ continue;
130
+ const wanted = change.ids.map((id) => detached.get(id) ?? this.#byId.get(id)).filter((e) => !!e);
131
+ const keep = new Set(wanted);
132
+ for (const child of [...parent.children]) {
133
+ if (!keep.has(child)) {
134
+ this.#forget(child);
135
+ child.remove();
136
+ }
137
+ }
138
+ // Only elements out of place move: the others stay where the screen reader may be reading.
139
+ wanted.forEach((e, i) => {
140
+ if (parent.children[i] !== e)
141
+ parent.insertBefore(e, parent.children[i] ?? null);
142
+ });
143
+ }
144
+ }
38
145
  }
39
- export function startA11y(s, dom, ledger) {
146
+ /** How long an announcement waits after emptying the live region. */
147
+ const LIVE_MS = 50;
148
+ /** How long after the focus comes into the editing element an announcement waits: past the focus's own speech. */
149
+ const AFTER_FOCUS_MS = 600;
150
+ /** How long after a mirror node's activation a press on the editor is the activation's own mouse click. */
151
+ const ACTIVATION_MS = 500;
152
+ /** Mirrors the tree and speaks announcements. `label` is the editor's accessible name; `focus` focuses the editing element. */
153
+ export function startA11y(s, dom, ledger, label, focus, report) {
40
154
  const tree = ledger.node(dom.layer, document.createElement('div'));
41
155
  tree.dataset.twip = 'a11y';
42
156
  tree.style.cssText = hidden;
@@ -45,18 +159,42 @@ export function startA11y(s, dom, ledger) {
45
159
  live.setAttribute('aria-live', 'polite');
46
160
  live.setAttribute('role', 'status');
47
161
  live.style.cssText = hidden;
162
+ const mirror = new Mirror(tree, label);
48
163
  const onMessage = (m) => {
49
164
  const r = m.payload;
50
165
  if (m.kind === Kind.HostRequest && r.type === 'a11y')
51
- tree.replaceChildren(build(r.tree));
166
+ mirror.update(r.update);
52
167
  };
53
168
  s.hostListeners.add(onMessage);
54
169
  s.disposers.push(() => s.hostListeners.delete(onMessage));
55
- // A repeated announcement is read again: the region empties first.
170
+ // Activating a node starts editing there. A screen reader's activation focuses the node, then clicks it, or clicks
171
+ // with the mouse where it is: that lands on the canvas, and is the activation's, not a click to place the caret.
172
+ let activated = -Infinity;
173
+ const activate = (e) => {
174
+ // Regions and the document hold no caret position: the nearest node inside one that does.
175
+ const at = e.target?.closest('[data-twip-id][tabindex]');
176
+ if (!at || s.destroyed)
177
+ return;
178
+ e.preventDefault();
179
+ activated = performance.now();
180
+ s.call(Method.AccessibleGo, Number(at.dataset.twipId)).then(focus, report);
181
+ };
182
+ ledger.listen(tree, 'click', activate);
183
+ ledger.listen(tree, 'focusin', activate);
184
+ // A repeated announcement is read again: the region empties first. One made as the focus comes into the editing
185
+ // element waits for the screen reader to start saying the focus (NVDA does about 150 ms later, and cancels what it
186
+ // was saying): it is then said after it, not cut off by it ("Editing sub-template Violation" from a button).
56
187
  let cancel;
188
+ let focusedAt = -Infinity;
189
+ ledger.listen(dom.layer, 'focusin', (e) => {
190
+ if (e.target.matches('[data-twip="input"]'))
191
+ focusedAt = performance.now();
192
+ });
57
193
  s.disposers.push(s.on('announcement', ({ text }) => {
58
194
  live.textContent = '';
59
195
  cancel?.();
60
- cancel = ledger.timeout(() => (live.textContent = text), 50);
196
+ const wait = Math.max(LIVE_MS, focusedAt + AFTER_FOCUS_MS - performance.now());
197
+ cancel = ledger.timeout(() => (live.textContent = text), wait);
61
198
  }));
199
+ return { activating: () => performance.now() - activated < ACTIVATION_MS };
62
200
  }
@@ -7,7 +7,7 @@ export interface Clipboard {
7
7
  as?: 'formatted' | 'text';
8
8
  }): Promise<void>;
9
9
  }
10
- export declare function startClipboard(s: Session, dom: Dom, input: HTMLTextAreaElement, ledger: Ledger, report: (e: unknown) => void, toViewport: (e: {
10
+ export declare function startClipboard(s: Session, dom: Dom, input: HTMLElement, ledger: Ledger, report: (e: unknown) => void, toViewport: (e: {
11
11
  clientX: number;
12
12
  clientY: number;
13
13
  }) => {
@@ -1,5 +1,6 @@
1
1
  import { EditorError } from '../errors.js';
2
2
  import { Kind, Method } from '../transport/protocol.js';
3
+ import { codeOf } from './keys.js';
3
4
  /** The last copy on the page: the system clipboard has no place for its token, so a paste brings it back (the engine
4
5
  * pastes the copy it names only while the text is still that copy's). */
5
6
  let lastCopy;
@@ -71,7 +72,7 @@ export function startClipboard(s, dom, input, ledger, report, toViewport) {
71
72
  // Ctrl+Shift+V pastes as text.
72
73
  let plain = false;
73
74
  ledger.listen(input, 'keydown', (e) => {
74
- plain = (e.ctrlKey || e.metaKey) && e.shiftKey && e.code === 'KeyV';
75
+ plain = (e.ctrlKey || e.metaKey) && e.shiftKey && codeOf(e) === 'KeyV';
75
76
  });
76
77
  ledger.listen(input, 'paste', (e) => {
77
78
  e.preventDefault();
package/dist/host/dom.js CHANGED
@@ -113,6 +113,8 @@ export function createDom(container, ledger) {
113
113
  // extent, in both directions.
114
114
  const scroller = element('div', 'editor', 'position:relative;width:100%;height:100%;overflow:auto;display:grid;outline:none;');
115
115
  const canvas = element('canvas', 'canvas', 'grid-area:1/1;position:sticky;left:0;top:0;align-self:start;justify-self:start;display:block;touch-action:none;');
116
+ // The pixels say nothing to assistive technology: the editing element and the accessibility DOM do.
117
+ canvas.setAttribute('aria-hidden', 'true');
116
118
  const content = element('div', 'content', 'grid-area:1/1;position:relative;pointer-events:none;');
117
119
  const layer = element('div', 'layer', 'position:absolute;inset:0;overflow:hidden;');
118
120
  content.append(layer);
@@ -1,3 +1,3 @@
1
1
  import type { Session } from '../editor.js';
2
2
  import type { Ledger } from './dom.js';
3
- export declare function startIme(s: Session, input: HTMLTextAreaElement, ledger: Ledger, report: (e: unknown) => void): void;
3
+ export declare function startIme(s: Session, input: HTMLElement, ledger: Ledger, report: (e: unknown) => void): void;
package/dist/host/ime.js CHANGED
@@ -3,9 +3,6 @@ export function startIme(s, input, ledger, report) {
3
3
  const compose = (phase, text) => s.input({ type: 'composition', phase, text }).catch(report);
4
4
  ledger.listen(input, 'compositionstart', () => compose(Phase.start, ''));
5
5
  ledger.listen(input, 'compositionupdate', (e) => compose(Phase.update, e.data));
6
- ledger.listen(input, 'compositionend', (e) => {
7
- compose(Phase.end, e.data);
8
- // The element only ever holds the composition.
9
- input.value = '';
10
- });
6
+ // The editing element puts its text back after the composition (host/input.ts).
7
+ ledger.listen(input, 'compositionend', (e) => compose(Phase.end, e.data));
11
8
  }
@@ -1,12 +1,15 @@
1
1
  import type { Session } from '../editor.js';
2
2
  import type { Dom, Ledger } from './dom.js';
3
+ /** What the editing element tells screen readers about leaving it. */
4
+ export declare const LEAVE_HINT = "Press F6, or Escape then Tab, to leave the editor";
3
5
  /** The input element of a mounted editor. */
4
6
  export interface Input {
5
- readonly element: HTMLTextAreaElement;
7
+ readonly element: HTMLElement;
6
8
  focus(): void;
7
9
  }
8
10
  /**
9
11
  * Starts keyboard and text input. `claims` says whether a chord is a command's, an application shortcut or a host
10
12
  * key (`shortcuts.lookup`): those keys are the editor's, and their browser defaults (Save, Print, Find) don't run.
13
+ * `label` is the editor's accessible name.
11
14
  */
12
- export declare function startInput(s: Session, dom: Dom, ledger: Ledger, report: (e: unknown) => void, claims: (chord: string) => boolean): Input;
15
+ export declare function startInput(s: Session, dom: Dom, ledger: Ledger, report: (e: unknown) => void, claims: (chord: string) => boolean, label: string): Input;
@@ -1,5 +1,5 @@
1
- import { Kind } from '../transport/protocol.js';
2
- import { keyNumber, modsOf } from './keys.js';
1
+ import { Kind, Method } from '../transport/protocol.js';
2
+ import { codeOf, keyNumber, modsOf } from './keys.js';
3
3
  /** Keys that edit or move, with Ctrl or Shift or neither (not Alt, which the browser keeps): the engine's. */
4
4
  const editingKeys = new Set([
5
5
  'Backspace', 'Delete', 'Enter', 'NumpadEnter', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End',
@@ -18,30 +18,189 @@ const names = {
18
18
  NumpadEnter: 'Enter',
19
19
  };
20
20
  /** A keydown as the shortcut strings spell it: `'Ctrl+Shift+>'`, by the key's code and by the character it made. */
21
- function chords(e) {
21
+ function chords(e, code) {
22
22
  // shortcut: Cmd is read as Ctrl, so the Windows shortcut strings serve the Mac too.
23
23
  const mods = `${e.ctrlKey || e.metaKey ? 'Ctrl+' : ''}${e.altKey ? 'Alt+' : ''}${e.shiftKey ? 'Shift+' : ''}`;
24
- const keys = new Set([names[e.code] ?? e.code.replace(/^Key|^Digit/, ''), e.code]);
24
+ const keys = new Set([names[code] ?? code.replace(/^Key|^Digit/, ''), code]);
25
25
  if (e.key.length === 1)
26
26
  keys.add(e.key.toUpperCase());
27
27
  return [...keys].map((k) => mods + k);
28
28
  }
29
+ /** What the editing element tells screen readers about leaving it. */
30
+ export const LEAVE_HINT = 'Press F6, or Escape then Tab, to leave the editor';
31
+ /** Elements that can be Tab stops. */
32
+ const STOPS = 'a[href],area[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable=""],[contenteditable="true"]';
33
+ /**
34
+ * F6 and Shift+F6, as in Word (the next pane): the focus goes to the next or previous Tab stop outside `editor`. Screen
35
+ * readers keep the first Escape for themselves (NVDA's leaves focus mode), so Escape then Tab doesn't get out with one.
36
+ */
37
+ // ponytail: document order and tabIndex >= 0, not positive tabindex order; follow it if a page relies on that.
38
+ function leave(from, editor, back) {
39
+ const stops = [...document.querySelectorAll(STOPS)].filter((e) => !editor.contains(e) && e.tabIndex >= 0 && !e.disabled && e.getClientRects().length > 0);
40
+ const after = (e) => (from.compareDocumentPosition(e) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
41
+ (back ? stops.filter((e) => !after(e)).at(-1) : stops.find(after))?.focus();
42
+ }
43
+ /** A segment's element: plain text, or an element carrying its marks and language. */
44
+ function segment(s) {
45
+ const marks = s.marks ?? [];
46
+ if (!marks.length && !s.lang)
47
+ return document.createTextNode(s.text);
48
+ const e = document.createElement(marks.includes('insertion') ? 'ins' : marks.includes('deletion') ? 'del' : 'span');
49
+ if (marks.includes('misspelling'))
50
+ e.setAttribute('aria-invalid', 'spelling');
51
+ if (s.author && (marks.includes('insertion') || marks.includes('deletion')))
52
+ e.setAttribute('aria-description', `by ${s.author}`);
53
+ if (s.lang)
54
+ e.lang = s.lang;
55
+ e.textContent = s.text;
56
+ return e;
57
+ }
58
+ /**
59
+ * A window paragraph's element: one block, so a screen reader's paragraph is the paragraph, with its lines where the
60
+ * canvas has them. Each line is a span that doesn't wrap, with a break point (`<wbr>`) after it; the element is 1 px
61
+ * wide, so the browser breaks at every break point and nowhere else: its lines are the canvas's. A `<br>` would end a
62
+ * paragraph for the screen reader too. A list item's label comes first.
63
+ */
64
+ function paragraph(p) {
65
+ const e = document.createElement('div');
66
+ e.dataset.twipId = String(p.id);
67
+ if (p.role === 'heading') {
68
+ e.setAttribute('role', 'heading');
69
+ e.setAttribute('aria-level', String(p.level ?? 1));
70
+ }
71
+ if (p.label) {
72
+ const label = document.createElement('span');
73
+ label.dataset.twipLabel = '';
74
+ label.style.whiteSpace = 'pre';
75
+ label.textContent = `${p.label} `;
76
+ e.append(label);
77
+ }
78
+ p.lines.forEach((line, i) => {
79
+ if (i > 0)
80
+ e.append(document.createElement('wbr'));
81
+ const l = document.createElement('span');
82
+ l.dataset.twipLine = '';
83
+ l.style.whiteSpace = 'pre';
84
+ for (const s of line.segments)
85
+ l.append(segment(s));
86
+ e.append(l);
87
+ });
88
+ // An empty paragraph still has a line to put the caret on.
89
+ if (!e.textContent || e.textContent === (p.label ? `${p.label} ` : ''))
90
+ e.append(document.createElement('br'));
91
+ return e;
92
+ }
93
+ /** The text nodes of a paragraph's element that count for offsets: not its label's. */
94
+ function texts(e) {
95
+ const out = [];
96
+ const walker = document.createTreeWalker(e, NodeFilter.SHOW_TEXT, {
97
+ acceptNode: (n) => ((n.parentElement?.closest('[data-twip-label]') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT)),
98
+ });
99
+ for (let n = walker.nextNode(); n; n = walker.nextNode())
100
+ out.push(n);
101
+ return out;
102
+ }
103
+ /** The DOM place of a window place. */
104
+ function domPoint(paragraphs, p) {
105
+ const e = paragraphs[p.paragraph];
106
+ if (!e)
107
+ return undefined;
108
+ let left = p.offset;
109
+ const nodes = texts(e);
110
+ for (const t of nodes) {
111
+ if (left <= t.length)
112
+ return { node: t, offset: left };
113
+ left -= t.length;
114
+ }
115
+ const last = nodes.at(-1);
116
+ // An empty paragraph: before its `<br>`, after its label.
117
+ return last ? { node: last, offset: last.length } : { node: e, offset: e.querySelector('[data-twip-label]') ? 1 : 0 };
118
+ }
119
+ /** The window place of a DOM place, or none outside the element's paragraphs. */
120
+ function windowPoint(paragraphs, node, offset) {
121
+ const k = paragraphs.findIndex((e) => e.contains(node));
122
+ const e = paragraphs[k];
123
+ if (!e)
124
+ return undefined;
125
+ // The text from the paragraph's start to the place, without the label (always first).
126
+ const r = document.createRange();
127
+ r.setStart(e, 0);
128
+ r.setEnd(node, offset);
129
+ const label = e.querySelector('[data-twip-label]')?.textContent?.length ?? 0;
130
+ return { paragraph: k, offset: Math.max(0, r.toString().length - label) };
131
+ }
132
+ const same = (a, b) => a?.paragraph === b?.paragraph && a?.offset === b?.offset;
29
133
  /**
30
134
  * Starts keyboard and text input. `claims` says whether a chord is a command's, an application shortcut or a host
31
135
  * key (`shortcuts.lookup`): those keys are the editor's, and their browser defaults (Save, Print, Find) don't run.
136
+ * `label` is the editor's accessible name.
32
137
  */
33
- export function startInput(s, dom, ledger, report, claims) {
34
- const input = ledger.node(dom.layer, document.createElement('textarea'));
138
+ export function startInput(s, dom, ledger, report, claims, label) {
139
+ const input = ledger.node(dom.layer, document.createElement('div'));
35
140
  input.dataset.twip = 'input';
36
- input.setAttribute('aria-label', 'Document');
141
+ input.contentEditable = 'true';
142
+ input.setAttribute('role', 'textbox');
37
143
  input.setAttribute('aria-multiline', 'true');
144
+ input.setAttribute('aria-label', label);
145
+ // What is edited is in the name, so the screen reader says it with the focus: "Document, sub-template Violation".
146
+ // (The switch's announcement can be lost while the screen reader reloads the page's content.)
147
+ s.disposers.push(s.on('editingTargetChanged', (t) => input.setAttribute('aria-label', t.kind === 'subTemplate' ? `${label}, sub-template ${t.name}` : label)));
148
+ input.setAttribute('aria-description', LEAVE_HINT);
38
149
  input.setAttribute('autocomplete', 'off');
39
150
  input.setAttribute('autocorrect', 'off');
40
151
  input.setAttribute('autocapitalize', 'off');
41
152
  input.spellcheck = false;
42
153
  input.style.cssText =
43
- 'position:absolute;left:0;top:0;width:1px;height:1em;opacity:0;border:0;padding:0;margin:0;resize:none;overflow:hidden;' +
44
- 'white-space:pre;outline:none;caret-color:transparent;pointer-events:none;';
154
+ 'position:absolute;left:0;top:0;width:1px;height:1em;opacity:0;border:0;padding:0;margin:0;overflow:hidden;' +
155
+ 'white-space:normal;overflow-wrap:normal;outline:none;caret-color:transparent;pointer-events:none;';
156
+ // The window: paragraphs by id, with what they were built from, so only changed ones are replaced.
157
+ const built = new Map();
158
+ let shown = [];
159
+ let latest;
160
+ let composing = false;
161
+ // The selection the host last set, so the selectionchange it causes isn't taken for the screen reader's.
162
+ let set;
163
+ const render = (w) => {
164
+ input.lang = w.lang;
165
+ if (w.readOnly)
166
+ input.setAttribute('aria-readonly', 'true');
167
+ else
168
+ input.removeAttribute('aria-readonly');
169
+ // The text is there only while the element has the focus: in browse mode a screen reader then finds the document
170
+ // once, in the structure mirror, and not again (the caret's paragraphs) in the editing element.
171
+ if (document.activeElement !== input)
172
+ return;
173
+ const next = w.paragraphs.map((p) => {
174
+ const from = JSON.stringify(p);
175
+ const old = built.get(p.id);
176
+ if (old?.from === from)
177
+ return old.e;
178
+ const e = paragraph(p);
179
+ built.set(p.id, { e, from });
180
+ return e;
181
+ });
182
+ const keep = new Set(next);
183
+ for (const child of [...input.children])
184
+ if (!keep.has(child))
185
+ child.remove();
186
+ for (const [id, b] of built)
187
+ if (!keep.has(b.e))
188
+ built.delete(id);
189
+ // Only elements out of place move: the others stay where the screen reader is reading.
190
+ next.forEach((e, i) => {
191
+ if (input.children[i] !== e)
192
+ input.insertBefore(e, input.children[i] ?? null);
193
+ });
194
+ shown = next;
195
+ const a = domPoint(shown, w.anchor);
196
+ const f = domPoint(shown, w.focus);
197
+ if (!a || !f)
198
+ return;
199
+ set = { anchor: w.anchor, focus: w.focus };
200
+ getSelection()?.setBaseAndExtent(a.node, a.offset, f.node, f.offset);
201
+ };
202
+ // Escape, then Tab or Shift+Tab: the browser moves the focus out; Escape still goes to the engine.
203
+ let escaped = false;
45
204
  // A modifier pressed or let go alone (Alt mid-drag): the engine runs the drag or hover again with it.
46
205
  const modifier = (e) => {
47
206
  if (!modifierKeys.has(e.key) || e.repeat)
@@ -53,42 +212,95 @@ export function startInput(s, dom, ledger, report, claims) {
53
212
  ledger.listen(input, 'keydown', (e) => {
54
213
  if (e.isComposing || e.keyCode === 229 || modifier(e))
55
214
  return;
56
- const key = keyNumber(e.code);
215
+ const code = codeOf(e);
216
+ if (code === 'F6' && !e.ctrlKey && !e.altKey && !e.metaKey) {
217
+ e.preventDefault();
218
+ escaped = false;
219
+ leave(input, dom.scroller, e.shiftKey);
220
+ return;
221
+ }
222
+ const leaving = escaped && code === 'Tab' && !e.ctrlKey && !e.altKey && !e.metaKey;
223
+ escaped = code === 'Escape' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
224
+ if (leaving)
225
+ return;
226
+ const key = keyNumber(code);
57
227
  if (!key)
58
228
  return;
59
- const all = chords(e);
229
+ const all = chords(e, code);
60
230
  if (all.some((c) => clipboardKeys.has(c.toLowerCase())))
61
231
  return;
62
232
  // The menu keys open the application's menu only when it listens; otherwise the browser's opens.
63
- const menu = e.code === 'ContextMenu' || all.includes('Shift+F10');
233
+ const menu = code === 'ContextMenu' || all.includes('Shift+F10');
64
234
  if (menu && !s.listening('contextMenu'))
65
235
  return;
66
236
  const claimed = all.some((c) => claims(c) || dialogKeys.has(c.toLowerCase()));
67
- if (!menu && !(!e.altKey && editingKeys.has(e.code)) && !claimed)
237
+ if (!menu && !(!e.altKey && editingKeys.has(code)) && !claimed)
68
238
  return;
69
239
  e.preventDefault();
70
240
  s.input({ type: 'key', key, mods: modsOf(e) }).catch(report);
71
241
  });
242
+ // Nothing edits the element itself: typed text goes to the engine, and the engine's window comes back. Composition
243
+ // can't be cancelled; the window is put back after it ends.
72
244
  ledger.listen(input, 'beforeinput', (e) => {
73
- if (e.inputType !== 'insertText' && e.inputType !== 'insertReplacementText')
245
+ if (e.inputType === 'insertCompositionText')
74
246
  return;
75
247
  e.preventDefault();
248
+ if (e.inputType !== 'insertText' && e.inputType !== 'insertReplacementText')
249
+ return;
76
250
  const text = e.data ?? e.dataTransfer?.getData('text/plain');
77
251
  if (text)
78
252
  s.input({ type: 'text', text }).catch(report);
79
253
  });
80
- const focused = (focused) => () => s.input({ type: 'focus', focused }).catch(report);
254
+ ledger.listen(input, 'compositionstart', () => (composing = true));
255
+ ledger.listen(input, 'compositionend', () => {
256
+ composing = false;
257
+ // The browser put the composition in the paragraphs: build them again.
258
+ built.clear();
259
+ if (latest)
260
+ render(latest);
261
+ });
262
+ const focused = (focused) => () => {
263
+ if (focused && latest)
264
+ render(latest);
265
+ if (!focused) {
266
+ input.replaceChildren();
267
+ built.clear();
268
+ shown = [];
269
+ }
270
+ s.input({ type: 'focus', focused }).catch(report);
271
+ };
81
272
  ledger.listen(input, 'focus', focused(true));
82
273
  ledger.listen(input, 'blur', focused(false));
83
- // The caret request: the input element follows the caret, so the IME's window opens beside it.
274
+ // A selection the host didn't set: the screen reader moved the caret (reading continuously). The engine follows.
275
+ ledger.listen(document, 'selectionchange', () => {
276
+ if (document.activeElement !== input || composing || s.destroyed)
277
+ return;
278
+ const sel = getSelection();
279
+ if (!sel?.anchorNode || !sel.focusNode)
280
+ return;
281
+ const anchor = windowPoint(shown, sel.anchorNode, sel.anchorOffset);
282
+ const focus = windowPoint(shown, sel.focusNode, sel.focusOffset);
283
+ if (!anchor || !focus || (same(anchor, set?.anchor) && same(focus, set?.focus)))
284
+ return;
285
+ set = { anchor, focus };
286
+ s.call(Method.HostCaretWindowSelect, { anchor, focus }).catch(report);
287
+ });
84
288
  const onMessage = (m) => {
85
- const r = m.payload;
86
- if (m.kind !== Kind.HostRequest || r.type !== 'caret')
289
+ if (m.kind !== Kind.HostRequest)
87
290
  return;
88
- input.style.left = `${r.x}px`;
89
- input.style.top = `${r.y}px`;
90
- input.style.height = `${Math.max(1, r.height)}px`;
91
- input.style.fontSize = `${Math.max(1, r.height * 0.8)}px`;
291
+ const r = m.payload;
292
+ // The caret request: the element follows the caret, so the IME's window opens beside it.
293
+ if (r.type === 'caret') {
294
+ input.style.left = `${r.x}px`;
295
+ input.style.top = `${r.y}px`;
296
+ input.style.height = `${Math.max(1, r.height)}px`;
297
+ input.style.fontSize = `${Math.max(1, r.height * 0.8)}px`;
298
+ }
299
+ else if (r.type === 'caret_text') {
300
+ latest = r.window;
301
+ if (!composing)
302
+ render(r.window);
303
+ }
92
304
  };
93
305
  s.hostListeners.add(onMessage);
94
306
  s.disposers.push(() => s.hostListeners.delete(onMessage));
@@ -1,5 +1,15 @@
1
1
  /** A key's number for the engine, or 0 when the engine doesn't know it. */
2
2
  export declare function keyNumber(code: string): number;
3
+ /**
4
+ * The key's code: `KeyboardEvent.code`, or, when it is empty (keys sent without a scan code, as screen readers, on-screen
5
+ * keyboards and automation do), the code `key` implies on a US layout: `ArrowDown`, `KeyA`, `Digit1`, `Space`. A keypad
6
+ * key that navigates (NumLock off, or a key sent without its extended flag) is the key it acts as: `Numpad2` is
7
+ * `ArrowDown`.
8
+ */
9
+ export declare function codeOf(e: {
10
+ code: string;
11
+ key: string;
12
+ }): string;
3
13
  /** Modifier bits of the C ABI. */
4
14
  export declare const Mods: {
5
15
  readonly ctrl: 1;
package/dist/host/keys.js CHANGED
@@ -19,6 +19,29 @@ const numbers = {
19
19
  export function keyNumber(code) {
20
20
  return Object.hasOwn(numbers, code) ? numbers[code] : 0;
21
21
  }
22
+ /** Keys whose `KeyboardEvent.key` is their code's name. */
23
+ const named = new Set(Object.keys(numbers).filter((c) => !/^(Key|Digit|Numpad|Intl)/.test(c) && c.length > 1));
24
+ /**
25
+ * The key's code: `KeyboardEvent.code`, or, when it is empty (keys sent without a scan code, as screen readers, on-screen
26
+ * keyboards and automation do), the code `key` implies on a US layout: `ArrowDown`, `KeyA`, `Digit1`, `Space`. A keypad
27
+ * key that navigates (NumLock off, or a key sent without its extended flag) is the key it acts as: `Numpad2` is
28
+ * `ArrowDown`.
29
+ */
30
+ export function codeOf(e) {
31
+ if (/^Numpad(\d|Decimal)$/.test(e.code) && named.has(e.key))
32
+ return e.key;
33
+ if (e.code && e.code !== 'Unidentified')
34
+ return e.code;
35
+ if (named.has(e.key))
36
+ return e.key;
37
+ if (e.key === ' ')
38
+ return 'Space';
39
+ if (/^[a-z]$/i.test(e.key))
40
+ return `Key${e.key.toUpperCase()}`;
41
+ if (/^[0-9]$/.test(e.key))
42
+ return `Digit${e.key}`;
43
+ return e.code;
44
+ }
22
45
  /** Modifier bits of the C ABI. */
23
46
  export const Mods = { ctrl: 1, shift: 2, alt: 4, meta: 8 };
24
47
  /** A keyboard event's modifier bits. */
@@ -51,12 +51,17 @@ export async function mountHost(container, s, options, claims) {
51
51
  });
52
52
  const scroll = startScroll(s, dom, ledger, frames, report);
53
53
  chrome = startChrome(s, dom, ledger, frames, report, toViewport);
54
- const input = startInput(s, dom, ledger, report, claims);
54
+ const label = options.label ?? 'Document';
55
+ const input = startInput(s, dom, ledger, report, claims, label);
55
56
  startIme(s, input.element, ledger, report);
56
57
  const clipboard = startClipboard(s, dom, input.element, ledger, report, toViewport);
57
58
  const balloons = chrome.balloonLayer;
58
- startPointer(s, dom, ledger, scroll, report, input.focus, (t) => t instanceof Node && balloons.contains(t) && t !== balloons);
59
- startA11y(s, dom, ledger);
59
+ const a11y = startA11y(s, dom, ledger, label, input.focus, report);
60
+ startPointer(s, dom, ledger, scroll, report, input.focus, (t) => (t instanceof Node && balloons.contains(t) && t !== balloons) || a11y.activating());
61
+ // Firefox makes a scrolling element a Tab stop, where a screen reader reads the whole mirror: the editor has one
62
+ // stop, the editing element, and focus given to the scroller goes on to it.
63
+ dom.scroller.tabIndex = -1;
64
+ ledger.listen(dom.scroller, 'focus', input.focus);
60
65
  if (options.background)
61
66
  await chrome.view.setBackground(options.background);
62
67
  if (options.overlayColors)
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ export type { AccessibilityDialogSession, DialogKind, DialogSession, DialogSessi
19
19
  export type { Find, FindMatch, FindQuery, FindResult } from './services/find.js';
20
20
  export type { Dictionary, Spelling } from './services/spelling.js';
21
21
  export type { Block, ControlKind, ControlOptions, DataBinding, DataParts, ElementDetail, ElementInfo, Elements, MountedElements, PlaceholderInfo, ReferenceResolution, ReferenceRule, SubTemplateInfo, SubTemplateReference, SubTemplates, } from './services/elements.js';
22
- export type { Accessibility, AccessibleNode, Shortcuts } from './services/accessibility.js';
22
+ export type { Accessibility, AccessibleId, AccessibleNode, AnnouncementKind, CellPlace, ListPosition, Shortcuts } from './services/accessibility.js';
23
23
  export type { Heading, Navigation, StyleInfo, Styles } from './services/navigation.js';
24
24
  export type { Ruler, RulerModel, RulerModelSource, RulerUnit, RulerView, TabAlignment, TabLeader, TabSelector, } from './services/ruler.js';
25
25
  export type { ChromeColors, ChromeTheme, DocumentAppearance, GestureAction, GestureBinding, GestureEvent, GestureId, Gestures, MeasureDetail, ObjectGrid, Popover, SystemColors, TabTypeChoice, ThemeColors, View, ViewOptions, } from './services/view.js';
@@ -5,22 +5,71 @@ import type { HostCommand } from '../events.js';
5
5
  export interface Accessibility {
6
6
  /** The tree. */
7
7
  tree(): Promise<AccessibleNode>;
8
+ /**
9
+ * Puts the caret at the start of a node's content and scrolls it into view: a note reference's note, a comment's
10
+ * commented text. Rejects with `'invalid-argument'` when no node has the id, and `'unavailable'` when the node holds
11
+ * no caret position (a region).
12
+ */
13
+ go(id: AccessibleId): Promise<void>;
14
+ /** The kinds of announcements the editor makes; the others are neither said nor emitted. Default: all. */
15
+ setAnnouncements(kinds: AnnouncementKind[]): Promise<void>;
8
16
  }
17
+ /**
18
+ * A node's identity: the same across edits while the thing it names exists (a paragraph, table, note, comment or
19
+ * story keeps its id when other content changes).
20
+ */
21
+ export type AccessibleId = number;
9
22
  /** A node of the accessibility tree. */
10
23
  export interface AccessibleNode {
11
- /** Its role. */
12
- role: 'document' | 'page' | 'paragraph' | 'heading' | 'list' | 'listItem' | 'table' | 'row' | 'cell' | 'image' | 'link' | 'textBox' | 'note' | 'header' | 'footer';
24
+ /** Its identity. */
25
+ id: AccessibleId;
26
+ /** Its role. `region`: a named group after the body (headers and footers, footnotes, endnotes, comments). */
27
+ role: 'document' | 'paragraph' | 'heading' | 'list' | 'listItem' | 'table' | 'row' | 'cell' | 'columnHeader' | 'rowHeader' | 'image' | 'link' | 'textBox' | 'note' | 'noteRef' | 'comment' | 'header' | 'footer' | 'region' | 'text';
13
28
  /** Its accessible name. */
14
29
  name?: string;
15
- /** Its text. */
30
+ /** A text node's text. */
16
31
  text?: string;
17
- /** A heading's or list item's level. */
32
+ /** A heading's level, a list item's nesting level, or a table's nesting level, from 1. */
18
33
  level?: number;
19
- /** Review and spelling marks on it. */
20
- marks?: ('insertion' | 'deletion' | 'comment' | 'misspelling')[];
34
+ /** A numbered list item's number as shown (`3.`, `a)`). */
35
+ label?: string;
36
+ /** A list item's position in its list. */
37
+ position?: ListPosition;
38
+ /** A cell's place in its table. */
39
+ cell?: CellPlace;
40
+ /** Review and spelling marks on a text node. */
41
+ marks?: ('insertion' | 'deletion' | 'misspelling')[];
42
+ /** Whose marks or comment it is. */
43
+ author?: string;
44
+ /** Its language (BCP 47), where it differs from its parent's. */
45
+ lang?: string;
46
+ /** The comment the text is in the range of. */
47
+ details?: AccessibleId;
48
+ /** The note a note reference points to; for a sub-template body shown in place, its reference's paragraph. */
49
+ target?: AccessibleId;
21
50
  /** Its children. */
22
51
  children: AccessibleNode[];
23
52
  }
53
+ /** Where a list item is in its list: its index (from 1) among the items at its level, and how many there are. */
54
+ export interface ListPosition {
55
+ /** Its index, from 1. */
56
+ index: number;
57
+ /** The number of items. */
58
+ count: number;
59
+ }
60
+ /** Where a cell is in its table's grid: its row and column (from 1) and how many it spans. */
61
+ export interface CellPlace {
62
+ /** Its row, from 1. */
63
+ row: number;
64
+ /** Its first grid column, from 1. */
65
+ col: number;
66
+ /** How many rows it spans. */
67
+ rowSpan: number;
68
+ /** How many grid columns it spans. */
69
+ colSpan: number;
70
+ }
71
+ /** What an announcement is about. */
72
+ export type AnnouncementKind = 'table' | 'list' | 'page' | 'review' | 'comment' | 'spelling' | 'formatting' | 'mode' | 'readOnly' | 'command' | 'selection';
24
73
  /** Keyboard shortcuts: the application's own, and what a key does now. */
25
74
  export interface Shortcuts {
26
75
  /** Claims a key for the application: pressing it emits `shortcut`. Throws 'unavailable' on a collision, naming it. */
@@ -2,7 +2,9 @@ import { EditorError } from '../errors.js';
2
2
  import { Method } from '../transport/protocol.js';
3
3
  /** The accessibility tree over a session. */
4
4
  export function createAccessibility(s) {
5
- return { tree: () => s.call(Method.AccessibleTree) };
5
+ return { tree: () => s.call(Method.AccessibleTree), go: (id) => s.call(Method.AccessibleGo, id),
6
+ setAnnouncements: (kinds) => s.call(Method.AnnouncementsSet, kinds),
7
+ };
6
8
  }
7
9
  /** The keys the application owns (the engine's host keys), by their Windows shortcut. */
8
10
  const hostKeys = {
@@ -1,7 +1,7 @@
1
1
  import { EditorError, type ErrorCode } from '../errors.js';
2
2
  import type { TwipEvents } from '../events.js';
3
3
  import type { FaceInfo } from '../fonts/library.js';
4
- import type { AccessibleNode } from '../services/accessibility.js';
4
+ import type { AccessibleId, AccessibleNode } from '../services/accessibility.js';
5
5
  import type { Warning } from '../types.js';
6
6
  /** Structured call numbers: `TwipMethod` in `twip.h`, the C ABI's `Method`. The two lists must match exactly. */
7
7
  export declare const Method: {
@@ -82,6 +82,9 @@ export declare const Method: {
82
82
  readonly HostSetLeaders: 75;
83
83
  readonly ShapeGallery: 76;
84
84
  readonly SubTemplates: 77;
85
+ readonly AccessibleGo: 78;
86
+ readonly HostCaretWindowSelect: 79;
87
+ readonly AnnouncementsSet: 80;
85
88
  };
86
89
  /** A structured call number. */
87
90
  export type Method = (typeof Method)[keyof typeof Method];
@@ -141,10 +144,59 @@ export type HostRequest = {
141
144
  width: number;
142
145
  height: number;
143
146
  }
144
- /** The accessibility tree changed: mirror it. */
147
+ /** The text around the caret and the selection in it changed: for the focused editing element. */
148
+ | {
149
+ type: 'caret_text';
150
+ window: CaretWindow;
151
+ }
152
+ /** The accessibility tree changed: the whole tree, or the changes since the last one. */
145
153
  | {
146
154
  type: 'a11y';
147
- tree: AccessibleNode;
155
+ update: A11yUpdate;
156
+ };
157
+ /** The text around the caret: the caret's paragraph and its neighbours in its container, in laid-out lines. */
158
+ export interface CaretWindow {
159
+ paragraphs: {
160
+ id: AccessibleId;
161
+ role: 'paragraph' | 'heading' | 'listItem';
162
+ level?: number;
163
+ /** A numbered item's number: not part of the text or its offsets. */
164
+ label?: string;
165
+ lines: {
166
+ segments: WindowSegment[];
167
+ }[];
168
+ }[];
169
+ anchor: WindowPoint;
170
+ focus: WindowPoint;
171
+ /** The selection is all of its story. */
172
+ all: boolean;
173
+ lang: string;
174
+ readOnly: boolean;
175
+ }
176
+ /** Text of a line with the same marks, author and language. */
177
+ export interface WindowSegment {
178
+ text: string;
179
+ marks?: ('insertion' | 'deletion' | 'misspelling')[];
180
+ author?: string;
181
+ lang?: string;
182
+ }
183
+ /** A place in a caret window: a paragraph's index and an offset in its text (characters, without the label). */
184
+ export interface WindowPoint {
185
+ paragraph: number;
186
+ offset: number;
187
+ }
188
+ /** The accessibility tree, or what changed in it: a node and its subtree replaced, or a node's children reordered. */
189
+ export type A11yUpdate = {
190
+ full: AccessibleNode;
191
+ } | {
192
+ patch: ({
193
+ type: 'replace';
194
+ node: AccessibleNode;
195
+ } | {
196
+ type: 'children';
197
+ parent: AccessibleId;
198
+ ids: AccessibleId[];
199
+ })[];
148
200
  };
149
201
  /** One engine-to-SDK message. `payload` is the decoded JSON, or undefined for kinds without one. */
150
202
  export interface Message {
@@ -81,6 +81,9 @@ export const Method = {
81
81
  HostSetLeaders: 75,
82
82
  ShapeGallery: 76,
83
83
  SubTemplates: 77,
84
+ AccessibleGo: 78,
85
+ HostCaretWindowSelect: 79,
86
+ AnnouncementsSet: 80,
84
87
  };
85
88
  /** What the engine sends: callback kinds of the C ABI. */
86
89
  export const Kind = {
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twip/web",
3
- "version": "0.0.0-canary-4d6177a",
3
+ "version": "0.0.0-canary-abcb2d5",
4
4
  "description": "The Twip DOCX editor for the browser and Node: a managed, framework-independent editor over the WebAssembly engine.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",