@twip/web 0.0.0-canary-092e560 → 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 +37 -1
- package/dist/commands.js +8 -0
- package/dist/editor.d.ts +5 -1
- package/dist/editor.js +4 -1
- package/dist/events.d.ts +19 -2
- 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 +3 -3
- package/dist/services/accessibility.d.ts +55 -6
- package/dist/services/accessibility.js +3 -1
- package/dist/services/elements.d.ts +74 -0
- package/dist/services/elements.js +21 -0
- package/dist/state.d.ts +14 -2
- package/dist/transport/protocol.d.ts +56 -3
- package/dist/transport/protocol.js +4 -0
- package/dist/twip_wasm.wasm +0 -0
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
package/dist/commands.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RichText } from './review.js';
|
|
2
|
-
import type { ControlKind, ControlOptions, DataBinding } from './services/elements.js';
|
|
2
|
+
import type { ControlKind, ControlOptions, DataBinding, ReferenceRule } from './services/elements.js';
|
|
3
3
|
import type { TabAlignment, TabLeader, TabSelector } from './services/ruler.js';
|
|
4
4
|
import type { ViewOptions } from './services/view.js';
|
|
5
5
|
import type { AutoCorrectChange, HighlightColor, HorizontalPosition, LineSpacing, MarkupView, ObjectWrap, RowHeight, TablePosition, TableWidth, UnderlineStyle, VerticalPosition, WrapSide } from './state.js';
|
|
@@ -476,6 +476,40 @@ export interface Commands {
|
|
|
476
476
|
labels: Record<string, string>;
|
|
477
477
|
merge?: boolean;
|
|
478
478
|
}): Promise<void>;
|
|
479
|
+
/** Which fields are sub-template references: replaces the rules; an empty list turns sub-templates off. View. */
|
|
480
|
+
setSubTemplateRules(options: {
|
|
481
|
+
rules: ReferenceRule[];
|
|
482
|
+
}): Promise<void>;
|
|
483
|
+
/** Shows references' sub-template bodies in their place, or their fields (default: every reference). View. */
|
|
484
|
+
setSubTemplateView(options: {
|
|
485
|
+
ids?: ElementId[];
|
|
486
|
+
expanded: boolean;
|
|
487
|
+
}): Promise<void>;
|
|
488
|
+
/** Edits a sub-template's body in this editor, on its own pages. View. */
|
|
489
|
+
editSubTemplate(options: {
|
|
490
|
+
name: string;
|
|
491
|
+
category?: string;
|
|
492
|
+
}): Promise<void>;
|
|
493
|
+
/** Back to the template from a sub-template, at the template's selection from before. View. */
|
|
494
|
+
closeSubTemplate(): Promise<void>;
|
|
495
|
+
/** A new sub-template, empty or with a DOCX's body (`category`: default the first rule's). */
|
|
496
|
+
createSubTemplate(options: {
|
|
497
|
+
name: string;
|
|
498
|
+
category?: string;
|
|
499
|
+
from?: Uint8Array;
|
|
500
|
+
}): Promise<void>;
|
|
501
|
+
/** Renames a sub-template, and with `updateReferences` every reference to it. */
|
|
502
|
+
renameSubTemplate(options: {
|
|
503
|
+
name: string;
|
|
504
|
+
to: string;
|
|
505
|
+
category?: string;
|
|
506
|
+
updateReferences?: boolean;
|
|
507
|
+
}): Promise<void>;
|
|
508
|
+
/** Removes a sub-template; its references no longer resolve. */
|
|
509
|
+
removeSubTemplate(options: {
|
|
510
|
+
name: string;
|
|
511
|
+
category?: string;
|
|
512
|
+
}): Promise<void>;
|
|
479
513
|
/** Inserts a content control, optionally around the selection. */
|
|
480
514
|
insertControl(options: {
|
|
481
515
|
kind: ControlKind;
|
|
@@ -646,6 +680,8 @@ export interface Commands {
|
|
|
646
680
|
showAutoCorrectOptions(): Promise<void>;
|
|
647
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. */
|
|
648
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>;
|
|
649
685
|
}
|
|
650
686
|
/** A command id: the method name, the batch op's `type`, the catalog id and the key in `state.commands`. */
|
|
651
687
|
export type CommandId = keyof Commands;
|
package/dist/commands.js
CHANGED
|
@@ -143,6 +143,13 @@ const encodings = {
|
|
|
143
143
|
unlinkFields: 'spread',
|
|
144
144
|
showFieldCodes: 'spread',
|
|
145
145
|
setFieldLabels: 'spread',
|
|
146
|
+
setSubTemplateRules: 'spread',
|
|
147
|
+
setSubTemplateView: 'spread',
|
|
148
|
+
editSubTemplate: 'spread',
|
|
149
|
+
closeSubTemplate: 'none',
|
|
150
|
+
createSubTemplate: 'spread',
|
|
151
|
+
renameSubTemplate: 'spread',
|
|
152
|
+
removeSubTemplate: 'spread',
|
|
146
153
|
insertControl: 'spread',
|
|
147
154
|
setControl: 'spread',
|
|
148
155
|
removeControl: 'spread',
|
|
@@ -184,6 +191,7 @@ const encodings = {
|
|
|
184
191
|
neverAutoCorrect: 'none',
|
|
185
192
|
showAutoCorrectOptions: 'none',
|
|
186
193
|
closePopover: 'none',
|
|
194
|
+
describeFormatting: 'none',
|
|
187
195
|
};
|
|
188
196
|
/** Builds a command's op from its method arguments. */
|
|
189
197
|
export function toOp(id, arg) {
|
package/dist/editor.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { FontLibrary } from './fonts/library.js';
|
|
|
4
4
|
import { type MountedReview, type Review } from './review.js';
|
|
5
5
|
import { type Accessibility, type Shortcuts } from './services/accessibility.js';
|
|
6
6
|
import { type Dialogs } from './services/dialogs.js';
|
|
7
|
-
import { type DataParts, type Elements, type MountedElements } from './services/elements.js';
|
|
7
|
+
import { type DataParts, type Elements, type MountedElements, type SubTemplates } from './services/elements.js';
|
|
8
8
|
import { type Find } from './services/find.js';
|
|
9
9
|
import { type Menus } from './services/menus.js';
|
|
10
10
|
import { type Navigation, type Styles } from './services/navigation.js';
|
|
@@ -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
|
}
|
|
@@ -151,6 +153,8 @@ export declare class HeadlessEditor {
|
|
|
151
153
|
readonly elements: Elements;
|
|
152
154
|
/** The custom XML data parts. */
|
|
153
155
|
readonly data: DataParts;
|
|
156
|
+
/** The template's sub-templates, and editing one in this editor. */
|
|
157
|
+
readonly subTemplates: SubTemplates;
|
|
154
158
|
/** The ruler model. */
|
|
155
159
|
readonly ruler: RulerModelSource;
|
|
156
160
|
/** The accessibility tree. */
|
package/dist/editor.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mountHost } from './host/mount.js';
|
|
|
6
6
|
import { createMountedReview, createReview } from './review.js';
|
|
7
7
|
import { createAccessibility, createShortcuts } from './services/accessibility.js';
|
|
8
8
|
import { createDialogs } from './services/dialogs.js';
|
|
9
|
-
import { createDataParts, createElements } from './services/elements.js';
|
|
9
|
+
import { createDataParts, createElements, createSubTemplates, } from './services/elements.js';
|
|
10
10
|
import { createFind } from './services/find.js';
|
|
11
11
|
import { createMenus } from './services/menus.js';
|
|
12
12
|
import { createNavigation, createStyles } from './services/navigation.js';
|
|
@@ -270,6 +270,8 @@ export class HeadlessEditor {
|
|
|
270
270
|
elements;
|
|
271
271
|
/** The custom XML data parts. */
|
|
272
272
|
data;
|
|
273
|
+
/** The template's sub-templates, and editing one in this editor. */
|
|
274
|
+
subTemplates;
|
|
273
275
|
/** The ruler model. */
|
|
274
276
|
ruler;
|
|
275
277
|
/** The accessibility tree. */
|
|
@@ -295,6 +297,7 @@ export class HeadlessEditor {
|
|
|
295
297
|
this.review = createReview(session);
|
|
296
298
|
this.elements = createElements(session);
|
|
297
299
|
this.data = createDataParts(session);
|
|
300
|
+
this.subTemplates = createSubTemplates(session);
|
|
298
301
|
this.ruler = { model: () => session.state.ruler };
|
|
299
302
|
this.accessibility = createAccessibility(session);
|
|
300
303
|
this.shortcuts = createShortcuts(session);
|
package/dist/events.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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';
|
|
6
7
|
import type { RulerView } from './services/ruler.js';
|
|
7
8
|
import type { GestureEvent, Popover } from './services/view.js';
|
|
8
|
-
import type { SelectionSummary } from './state.js';
|
|
9
|
+
import type { EditingTarget, SelectionSummary } from './state.js';
|
|
9
10
|
import type { Author, ClientPoint, ClientRect, CommentId, ElementId, RevisionId, Warning } from './types.js';
|
|
10
11
|
/** Every event of `editor.on(type, listener)`, keyed by type. */
|
|
11
12
|
export interface TwipEvents {
|
|
@@ -13,6 +14,8 @@ export interface TwipEvents {
|
|
|
13
14
|
documentChanged: TransactionRecord;
|
|
14
15
|
/** The selection changed; coalesced per frame. */
|
|
15
16
|
selectionChanged: SelectionSummary;
|
|
17
|
+
/** The editor switched between the template and a sub-template, or between sub-templates: where it is now. */
|
|
18
|
+
editingTargetChanged: EditingTarget;
|
|
16
19
|
/** A link was activated: a click when read only, Ctrl+click otherwise. */
|
|
17
20
|
linkActivated: {
|
|
18
21
|
address: string;
|
|
@@ -23,6 +26,19 @@ export interface TwipEvents {
|
|
|
23
26
|
key: string;
|
|
24
27
|
element: ElementId | null;
|
|
25
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* Typing, pasting or dropping was refused: into a sub-template's body shown in place of its reference (edit the
|
|
31
|
+
* sub-template instead), or into a locked control.
|
|
32
|
+
*/
|
|
33
|
+
editRefused: {
|
|
34
|
+
reason: {
|
|
35
|
+
kind: 'subTemplate';
|
|
36
|
+
name: string;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'locked';
|
|
39
|
+
};
|
|
40
|
+
element?: ElementId;
|
|
41
|
+
};
|
|
26
42
|
/** The user asked for a context menu. */
|
|
27
43
|
contextMenu: ContextMenuEvent;
|
|
28
44
|
/**
|
|
@@ -86,9 +102,10 @@ export interface TwipEvents {
|
|
|
86
102
|
rulerChanged: RulerView;
|
|
87
103
|
/** Mounted only: hover over a link, element mark, revision or comment, or `null` when it ends. */
|
|
88
104
|
tooltip: Tooltip | null;
|
|
89
|
-
/** 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). */
|
|
90
106
|
announcement: {
|
|
91
107
|
text: string;
|
|
108
|
+
kind: AnnouncementKind;
|
|
92
109
|
};
|
|
93
110
|
/** A fidelity warning outside an operation. */
|
|
94
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;
|