@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.
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +1 -0
- package/dist/editor.d.ts +2 -0
- package/dist/events.d.ts +3 -1
- package/dist/host/a11y.d.ts +7 -1
- package/dist/host/a11y.js +178 -40
- package/dist/host/clipboard.d.ts +1 -1
- package/dist/host/clipboard.js +2 -1
- package/dist/host/dom.js +2 -0
- package/dist/host/ime.d.ts +1 -1
- package/dist/host/ime.js +2 -5
- package/dist/host/input.d.ts +5 -2
- package/dist/host/input.js +234 -22
- package/dist/host/keys.d.ts +10 -0
- package/dist/host/keys.js +23 -0
- package/dist/host/mount.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/services/accessibility.d.ts +55 -6
- package/dist/services/accessibility.js +3 -1
- package/dist/transport/protocol.d.ts +55 -3
- package/dist/transport/protocol.js +3 -0
- package/dist/twip_wasm.wasm +0 -0
- package/package.json +1 -1
package/dist/commands.d.ts
CHANGED
|
@@ -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
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;
|
package/dist/host/a11y.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import type { Session } from '../editor.js';
|
|
2
2
|
import type { Dom, Ledger } from './dom.js';
|
|
3
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
paragraph: 'paragraph',
|
|
6
|
-
heading: 'heading',
|
|
7
|
-
list: 'list',
|
|
8
|
-
listItem: 'listitem',
|
|
9
|
-
table: 'table',
|
|
10
|
-
row: 'row',
|
|
11
|
-
cell: 'cell',
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
mirror.update(r.update);
|
|
52
167
|
};
|
|
53
168
|
s.hostListeners.add(onMessage);
|
|
54
169
|
s.disposers.push(() => s.hostListeners.delete(onMessage));
|
|
55
|
-
// A
|
|
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
|
-
|
|
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
|
}
|
package/dist/host/clipboard.d.ts
CHANGED
|
@@ -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:
|
|
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
|
}) => {
|
package/dist/host/clipboard.js
CHANGED
|
@@ -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
|
|
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);
|
package/dist/host/ime.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
7
|
-
|
|
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
|
}
|
package/dist/host/input.d.ts
CHANGED
|
@@ -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:
|
|
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;
|
package/dist/host/input.js
CHANGED
|
@@ -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[
|
|
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('
|
|
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.
|
|
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;
|
|
44
|
-
'white-space:
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
86
|
-
if (m.kind !== Kind.HostRequest || r.type !== 'caret')
|
|
289
|
+
if (m.kind !== Kind.HostRequest)
|
|
87
290
|
return;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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));
|
package/dist/host/keys.d.ts
CHANGED
|
@@ -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. */
|
package/dist/host/mount.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
12
|
-
|
|
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
|
-
/**
|
|
30
|
+
/** A text node's text. */
|
|
16
31
|
text?: string;
|
|
17
|
-
/** A heading's
|
|
32
|
+
/** A heading's level, a list item's nesting level, or a table's nesting level, from 1. */
|
|
18
33
|
level?: number;
|
|
19
|
-
/**
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
package/dist/twip_wasm.wasm
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twip/web",
|
|
3
|
-
"version": "0.0.0-canary-
|
|
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",
|