u2ui 1.5.13 → 1.5.15

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/el/ico/ico.js CHANGED
@@ -131,13 +131,11 @@ async function loadSvgString(url) {
131
131
  }
132
132
  }
133
133
 
134
- const svg = await loadCached(url);
135
- // temporary cache svg
136
-
137
134
  // external element
138
- if (hash) { // todo, cache the svgDoc?
139
- const parser = new DOMParser();
140
- const svgDoc = parser.parseFromString(svg, "image/svg+xml");
135
+ if (hash) {
136
+ url.hash = '';
137
+ const svgDoc = await (cachedDocuments[url] ??= loadCached(url).then(svg =>
138
+ new DOMParser().parseFromString(svg, "image/svg+xml")));
141
139
  const element = svgDoc.getElementById(hash);
142
140
  if (element) {
143
141
  return element.outerHTML;
@@ -147,10 +145,11 @@ async function loadSvgString(url) {
147
145
  }
148
146
 
149
147
  // external document
150
- return svg;
148
+ return loadCached(url);
151
149
  }
152
150
 
153
151
  const cachedRequests = {};
152
+ const cachedDocuments = {};
154
153
 
155
154
  async function loadCached(url) {
156
155
  return cachedRequests[url] ??= fetch(url, {cache: "force-cache"}).then(res => {
package/js/rte/PLAN.md CHANGED
@@ -79,17 +79,53 @@ without a single tag it is text, escaped and joined by breaks, which is all the
79
79
  structure such a paste has. A `text/plain` paste with no html flavor beside it
80
80
  stays native as before.
81
81
 
82
+ A host is read once per burst of work. `Surface.config` kept resolving a computed
83
+ style on every access, and an availability check asks for it several times — a
84
+ toolbar refresh went down that path 65 times. It now holds its reading until
85
+ `invalidate()`, which `refresh()` calls before anything looks: 65 style
86
+ resolutions became 2. `Edit.model` and `Commands.model` stopped asking the
87
+ platform a second question they already had the answer to, which is most of that
88
+ difference. Measured in Firefox 155 on the playground: `unstyle` availability
89
+ 600 → 200 µs, the ordinary marks 150 → 100 µs. What is left in a refresh is a
90
+ forced layout, which the placement of a toolbar that follows the caret cannot
91
+ avoid, and one `Edit` per availability check — 21 controls, two questions each.
92
+ Sharing one edit across a refresh is the next thing to try.
93
+
94
+ What a field may carry is now declared where every other host decision is
95
+ declared: `--u2-rte-elements`, `--u2-rte-attributes` and `--u2-rte-protocols`,
96
+ one grammar for the element-specific ones — a bare list for every element,
97
+ `name(…)` for one, and the attribute named inside for protocols. Each axis
98
+ declared *is* that axis, so a site may name what the shipped default does not.
99
+ That is not a hole: a client sanitizer protects the editing session — markup
100
+ inserted into the author's own logged-in page — while what may be stored is
101
+ decided where content is saved and served, and CSS is no less the application's
102
+ than JavaScript is.
103
+
104
+ Sanitizing now speaks the web Sanitizer API's vocabulary throughout — `elements`,
105
+ `removeElements`, `replaceWithChildrenElements`, `attributes`, `comments`,
106
+ `dataAttributes` — so browser, editor, and an application's own filter name the
107
+ same things alike. `protocols` is the one addition, because `clean()` also runs on
108
+ nodes a browser inserted, where no sink checked them. A configuration may not
109
+ carry `elements` and `removeElements` at once: the api rejects that pair and
110
+ `setHTML()` yields nothing, so an allowlist expresses removal by leaving out.
111
+
112
+ Inline `style` is presentation, not execution, and moved to the stage that owns
113
+ presentation. The security policy now allows the attribute — it runs no script —
114
+ while Unstyle keeps removing it from imported content, which is where unwanted
115
+ foreign styling actually comes from. Content a field owns therefore keeps what an
116
+ editor set on it, and an application that must not carry style at all narrows the
117
+ policy's `attributes`.
118
+
82
119
  Keeping the content of an unlisted element is right for almost all of them, and
83
120
  wrong for the few a browser never renders the children of: unwrapping a `<style>`
84
121
  from a word processor left its stylesheet standing in the text. `SanitizePolicy`
85
- now carries a `drop` list — `base head link meta noscript script style template
122
+ now carries a `removeElements` list — `base head link meta noscript script style template
86
123
  title` — that `narrow()` removes with their content, while everything else keeps
87
124
  dissolving into what the author wrote.
88
125
 
89
126
  A native paste now meets the attribute policy. Nothing is parsed there — the
90
127
  browser inserts its own payload — so it was the one import path without an
91
- allowlist, and layout ids, tracking attributes and inline styles came straight
92
- through. The pipeline applies `SanitizePolicy.clean()` to the nodes that
128
+ allowlist, and layout ids and tracking attributes came straight through. The pipeline applies `SanitizePolicy.clean()` to the nodes that
93
129
  arrived, before presentation cleanup and structural repair, so an id removed in
94
130
  the first stage is what lets the third dissolve the wrapper carrying it.
95
131
  `--u2-rte-import-sanitize: none` opts a host out.
@@ -458,6 +494,32 @@ absent, work for current and future surfaces, and release everything through
458
494
  editor and keyboard resizing remain open, as does a hook for regenerating the
459
495
  file server-side rather than letting the browser stretch it.
460
496
 
497
+ 6. **Assistant — implemented.** `aiView({request})` prompts over the whole field
498
+ and shows the answer beside the original, editable, with an optional third
499
+ pane for a diff the application supplies. Applying goes through `Source`, so
500
+ an answer is external input like every other and meets the sanitizer instead
501
+ of reaching `innerHTML`. The extension carries no provider, key, or prompt
502
+ text: `request({prompt, html})` is mandatory, which is why `rte.js` does not
503
+ install it and `ai.js` exports only the factory. Each request remembers the
504
+ run it belongs to, so an answer arriving after the next prompt or after the
505
+ dialog closed is dropped rather than filling a stale pane. The request also
506
+ carries the surface itself: what it allows — elements, content classes, default
507
+ block — because everything an answer uses beyond that is cleaned away when it
508
+ is applied, and a model that is not told promises what it then loses; and the
509
+ element behind it, because what else a field means is the application's to
510
+ read, not this module's to enumerate. Streaming a
511
+ growing answer, working on a selection rather than the whole field, and
512
+ keeping the prompt history remain open.
513
+
514
+ 7. **Declared block styles — implemented.** A block style was always a selector, a
515
+ tag and optional write/clear hooks, so `--u2-rte-blocks: Absatz(p),
516
+ Lead(p.lead), Notiz(p[data-note])` needed no new concept: the label names it,
517
+ the tag is what gets created, and every condition after it is written when the
518
+ style is applied and cleared when another replaces it. Recognition now takes
519
+ the most specific match instead of the last declared, so a plain tag and its
520
+ variants live side by side without `:not()` chains. What a control offers is a
521
+ host question again; which module registers it stays the application's.
522
+
461
523
  All roaming and contextual extension UI must be created in the core's
462
524
  `Document` or `ShadowRoot`, use the browser top layer through Popover or Dialog
463
525
  where appropriate, and share one placement policy.
package/js/rte/README.md CHANGED
@@ -91,6 +91,7 @@ tests, and documentation.
91
91
 
92
92
  ```text
93
93
  rte/
94
+ ├── ai.js Optional assistant module factory (no default install)
94
95
  ├── blocks.js Optional block-style module and public entry
95
96
  ├── classes.js Optional content-class control module and entry
96
97
  ├── breaks.js Optional visible-line-break extension and entry
package/js/rte/ai.js ADDED
@@ -0,0 +1,4 @@
1
+ export {aiView} from './src/client/ai.js';
2
+
3
+ // No `editor.add()` here: an assistant needs a request function, and which model answers is the
4
+ // application's choice — `editor.add(aiView({request}))` is the whole setup.
@@ -179,6 +179,14 @@
179
179
  details { font-size: .9em; }
180
180
  details > summary { cursor: pointer; }
181
181
  .wide { grid-column: 1 / -1; }
182
+ /* what the declared block styles write: a class on one, an attribute on the other */
183
+ .Lead { font-size: 1.15em; font-weight: 600 }
184
+ [data-note] { border-inline-start: 3px solid; opacity: .75; padding-inline-start: .6em }
185
+ .Red { color: #c00 }
186
+ .Green { color: #0a0 }
187
+ .Left { text-align: left }
188
+ .Center { text-align: center }
189
+ .Right { text-align: right }
182
190
  </style>
183
191
  </head>
184
192
  <body>
@@ -283,7 +291,7 @@
283
291
  <section class="panel wide">
284
292
  <h2>One-import editor prototype</h2>
285
293
  <div class="prototype">
286
- <div id="editor-prototype" contenteditable style="--u2-rte:true; --u2-rte-toolbar:undo redo block style bold italic underline strike code link bullets numbers outdent indent rule insertTable imageOriginal unstyle breaks source; --u2-rte-classes:lead, caption, brandColor; --u2-rte-show-breaks:true">
294
+ <div id="editor-prototype" contenteditable style="--u2-rte:true; --u2-rte-toolbar:undo redo block style bold italic underline strike code link bullets numbers outdent indent rule insertTable imageOriginal unstyle breaks source ai; --u2-rte-blocks:Absatz(p), Lead(p.Lead), Notiz(p[data-note]), Zitat(blockquote), Titel(h1), Untertitel(h2); --u2-rte-classes:lead, caption, brandColor, Red, Green, Left, Center, Right, Lead; --u2-rte-class-groups:color(Red Green), align(Left Center Right); --u2-rte-show-breaks:true">
287
295
  <p>Select text to format it, choose its block style, or press Enter inside this list:</p>
288
296
  <ul><li>List item</li><li>Put the caret here and press the indent control</li></ul>
289
297
  <p>Turn these paragraphs into a list, then lift them out again.</p>
@@ -319,7 +327,8 @@
319
327
  Input types without a command run cleanup policies only; they do not
320
328
  imitate the browser's native DOM mutation, so edit or apply HTML first.
321
329
  The prototype toolbar lists every ready control, so the order in
322
- <code>--u2-rte-toolbar</code> is the order shown. Undo and redo also
330
+ <code>--u2-rte-toolbar</code> is the order shown — the list decides both which
331
+ controls appear and where. Undo and redo also
323
332
  work from Ctrl/Command+Z and +Y; the readout under the surface reports
324
333
  how many entries the history holds and where it stands, which makes the
325
334
  grouping visible: continuous typing coalesces, while each command,
@@ -351,11 +360,14 @@
351
360
  structure itself — lists, tables, quotes and headings all reduce to
352
361
  plain blocks. Every control's key works whether or not it is listed in
353
362
  <code>--u2-rte-toolbar</code>; hover a control to see its shortcut.
354
- The style list offers exactly the names in
355
- <code>--u2-rte-classes</code>. That one declaration is also what the
356
- sanitizer keeps from pasted HTML and what remove-format leaves alone, so
357
- a class the host knows is never offered without being allowed, and never
358
- cleaned away as foreign.
363
+ <code>--u2-rte-classes</code> says which classes may exist: it is what the
364
+ sanitizer keeps from pasted HTML and what remove-format leaves alone.
365
+ What the style menu <em>offers</em> is
366
+ <code>--u2-rte-class-groups</code> — <code>color(Red Green)</code> is one
367
+ either-or set, and a field carries one class from each set at a time.
368
+ Block styles are declared the same way in <code>--u2-rte-blocks</code>:
369
+ <code>Lead(p.Lead)</code> creates a paragraph and writes the class with
370
+ it, <code>Notiz(p[data-note])</code> an attribute instead.
359
371
  Bold recognizes both <code>&lt;strong&gt;</code> and <code>&lt;b&gt;</code>,
360
372
  creates canonical <code>&lt;strong&gt;</code>, and unwraps either form on
361
373
  removal. The class-mark buttons reuse a fully selected inline element;
@@ -375,6 +387,7 @@
375
387
  import '../breaks.js';
376
388
  import '../unstyle.js';
377
389
  import {externalInputs, NativeSanitizer} from '../rte.js';
390
+ import {aiView} from '../ai.js';
378
391
  import {rangeRect} from '../src/browser/range-rect.js';
379
392
  import {Commands} from '../src/command/commands.js';
380
393
  import {deleteBackward, deleteForward} from '../src/command/delete.js';
@@ -406,6 +419,28 @@
406
419
  conventionEditor.add(externalInputs({sanitizer: new NativeSanitizer()}));
407
420
  }
408
421
 
422
+ // The assistant has no provider of its own, so the playground answers itself: the stub marks
423
+ // every word the prompt names and reports what it was asked, which is enough to see the dialog,
424
+ // the editable answer, the diff pane and what applying writes back.
425
+ //
426
+ // Comparing two html strings is a library question, so the diff is the application's to choose
427
+ // and is fetched on first use — a pane nobody opens costs nothing.
428
+ let htmlDiff = null;
429
+ globalThis.__client = conventionEditor; // measuring hook for the browser runner
430
+ conventionEditor.add(aiView({
431
+ prompts: ['Bold the keywords', 'Shorten', 'Continue'],
432
+ request: ({prompt, html}) => new Promise(resolve => setTimeout(() => resolve(
433
+ `<p><i>answered: ${prompt.replace(/[<&]/g, '')}</i></p>`
434
+ // Only the text between tags: bolding the whole string would edit attributes too.
435
+ + html.replace(/>([^<]+)</g, (all, text) => '>' + text.replace(/\b(\w{6,})\b/g, '<strong>$1</strong>') + '<'),
436
+ ), 400)),
437
+ diff: async (original, edited) => {
438
+ htmlDiff ??= import('https://cdn.jsdelivr.net/npm/htmldiff-js@1.0.5/+esm')
439
+ .then(module => module.default.default ?? module.default);
440
+ return (await htmlDiff).execute(original, edited);
441
+ },
442
+ }));
443
+
409
444
  const controls = {
410
445
  scenario: document.querySelector('#scenario'),
411
446
  level: document.querySelector('#level'),
@@ -809,13 +844,16 @@
809
844
  label.append(`--u2-rte-${name}`, select);
810
845
  properties.append(label);
811
846
  }
812
- const classes = document.createElement('label');
813
- const input = document.createElement('input');
814
- input.type = 'text';
815
- input.value = prototype.style.getPropertyValue('--u2-rte-classes').trim();
816
- input.addEventListener('input', () => write('classes', input.value));
817
- classes.append('--u2-rte-classes', input);
818
- properties.append(classes);
847
+ // The text-valued host properties: what may exist, what the menus offer, and the block styles.
848
+ for (const name of ['classes', 'class-groups', 'blocks']) {
849
+ const label = document.createElement('label');
850
+ const input = document.createElement('input');
851
+ input.type = 'text';
852
+ input.value = prototype.style.getPropertyValue(`--u2-rte-${name}`).trim();
853
+ input.addEventListener('input', () => write(name, input.value));
854
+ label.append(`--u2-rte-${name}`, input);
855
+ properties.append(label);
856
+ }
819
857
  }
820
858
 
821
859
  function writeToolbar() {
@@ -22,6 +22,13 @@ when focus reaches it; the client then installs its standard command registry
22
22
  and input pipeline. A default roaming toolbar is allocated only when the first
23
23
  rich-text surface becomes active. `plaintext-only` hosts stay completely native.
24
24
 
25
+ The optional `blocks.js` entry adds the block-style control. A host may name its
26
+ own with `--u2-rte-blocks: Absatz(p), Lead(p.lead), Notiz(p[data-note])`:
27
+ `declaredStyles()` turns each selector into a style whose tag is created and
28
+ whose conditions are written and cleared with it, so a block class needs no code.
29
+ Recognition takes the most specific match, which is why a plain tag and its
30
+ variants can be declared side by side.
31
+
25
32
  The optional `classes.js` entry adds a content-class control. Its choices come
26
33
  from the host's `--u2-rte-classes`, so its toolbar select is filled per surface
27
34
  rather than at registration: a module control may declare `options` as a
@@ -29,6 +36,21 @@ function of the surface, and the client refills that select before the toolbar
29
36
  is shown. The same declaration is what the sanitizer keeps and what
30
37
  presentation cleanup leaves alone.
31
38
 
39
+ One set of classes is one mark and therefore one either-or choice. A field that
40
+ combines axes — a colour *and* an alignment — names them in
41
+ `--u2-rte-class-groups: color(Red Green), align(Left Center Right)`, and each set
42
+ becomes a section of the same menu: exclusive in itself, free of the others.
43
+
44
+ The control is a menu rather than a select for that reason: a select carries one
45
+ value, and the whole point is carrying one from each set. It is also why the
46
+ sections can come from CSS at all — a toolbar builds its controls once, when a
47
+ module is registered, so how many *controls* there are cannot depend on a host,
48
+ while what one control *contains* is read per surface like every other choice.
49
+
50
+ The names still have to be in `--u2-rte-classes`: what may exist and what is
51
+ offered are separate questions, and a class foreign content may carry needs no
52
+ control at all.
53
+
32
54
  The optional `images.js` entry frames a selected image, with handles on its
33
55
  trailing edges and its alt text in a field below it. Both come and go with the
34
56
  selection and neither takes the focus by appearing, so naming an image is one
@@ -94,8 +116,13 @@ arrow keys move through it, Enter takes the current entry, Escape closes the lis
94
116
  before it closes the form, and a pointer does the same. One question is in flight
95
117
  at a time, and a late answer to a word that has since changed is dropped.
96
118
 
97
- Contextual UI goes with the editing session and comes back with it. `follows()`
98
- owns that lifecycle for every module that draws one: selection, content, and the
119
+ Contextual UI goes with the editing session and comes back with it, and it draws
120
+ only while there is one. A selection is not a session: engines leave a selection
121
+ inside an editable nobody focused — clicking beside one does that — and the core
122
+ captures it before deciding that no session follows. Drawing on that capture
123
+ would put a link form or a set of handles on a caret the keyboard cannot reach,
124
+ which is what the toolbar already refuses. `follows()` owns that lifecycle for
125
+ every module that draws one: selection, content, and the
99
126
  session's return, because coming back to the selection a module was already
100
127
  showing for is no selection change and would otherwise leave its handles or its
101
128
  form behind. A module that has more of its own to hear ends it with the same
@@ -169,7 +196,9 @@ Those defaults arrive as three ordinary modules — `history`, `marks`, and
169
196
  contract below and can be removed with `editor.delete('structure')`, so nothing
170
197
  about them is privileged. Every control they contribute is gated by
171
198
  `--u2-rte-toolbar`, so a host that lists only `bold` shows only Bold while the
172
- commands and their keyboard shortcuts stay available.
199
+ commands and their keyboard shortcuts stay available. The list is also the order
200
+ they appear in: what a toolbar looks like is the host's to say, and without a
201
+ declaration the controls keep the order their modules were registered in.
173
202
 
174
203
  `add(module)` installs an optional extension into already registered and future
175
204
  rich-text surfaces. A module has one client-wide name and may provide a command
@@ -349,6 +378,39 @@ available through `[Symbol.dispose]()`; core disposal disposes the client. One
349
378
  core accepts only one convention client at a time, preventing duplicate input
350
379
  pipelines. Disposal releases that ownership so a new client can take over.
351
380
 
381
+ ### Optional assistant
382
+
383
+ `aiView(options)` adds a prompt over the whole field, shown beside the original
384
+ with the answer in an editable pane. Applying writes it back through the source
385
+ path, so a model's output is external input like any other and meets the
386
+ sanitizer. Which model answers is application policy, so `request` is mandatory
387
+ and has no default:
388
+
389
+ ```js
390
+ import {editor} from '../../rte.js';
391
+ import {aiView} from '../../ai.js';
392
+
393
+ editor.add(aiView({
394
+ request: ({prompt, html}) => ask(prompt, html), // returns html
395
+ prompts: ['Shorten', 'Continue', 'Fix spelling'], // optional suggestions
396
+ diff: (original, edited) => mark(original, edited), // optional third pane
397
+ }));
398
+ ```
399
+
400
+ `request({prompt, html})` receives the field's serialized content and returns
401
+ its replacement, as a promise or directly. `surface` comes along rather than a
402
+ digest of it: `surface.config` says what the field allows — with `null` where
403
+ nothing is restricted — because an answer using anything else is cleaned away on
404
+ the way back in, and `surface.element` is where an application reads whatever
405
+ else its own fields carry; a rejection is shown in the answer
406
+ pane and leaves the field untouched. `prompts` fills a datalist beside the
407
+ input. Without `diff` the dialog shows two panes — comparing two HTML strings is
408
+ a library question, not an editor one, so none is pulled in. Answers that arrive
409
+ after the next prompt, or after the dialog closed, are dropped.
410
+
411
+ `rte.js` does not install it, and `ai.js` exports only the factory: an assistant
412
+ without a configured request would be a button that cannot work.
413
+
352
414
  ## Invariants
353
415
 
354
416
  - Importing the convention client adds only core listeners. It scans no DOM,
@@ -0,0 +1,228 @@
1
+ import {Source} from '../source/source.js';
2
+
3
+ const STYLE = `
4
+ #ai {
5
+ block-size: min(80vh, 50em);
6
+ border: 1px solid;
7
+ inline-size: min(95vw, 80em);
8
+ max-block-size: none;
9
+ max-inline-size: none;
10
+ overflow: hidden; /* only the text boxes scroll, never the dialog itself */
11
+ padding: 0;
12
+ pointer-events: auto;
13
+
14
+ & form {
15
+ block-size: 100%;
16
+ box-sizing: border-box;
17
+ display: flex;
18
+ flex-direction: column;
19
+ gap: .57em;
20
+ margin: 0;
21
+ padding: .57em;
22
+ }
23
+ & .prompt {
24
+ display: flex;
25
+ gap: .57em;
26
+
27
+ & input { flex: 1; min-inline-size: 0 }
28
+ }
29
+ & .panes {
30
+ display: flex;
31
+ flex: 1;
32
+ flex-wrap: wrap;
33
+ gap: .57em;
34
+ min-block-size: 0;
35
+ overflow: auto; /* wrapped panes stack: that column scrolls, the dialog stays put */
36
+ }
37
+ & .pane {
38
+ display: flex;
39
+ flex: 1 1 20em;
40
+ flex-direction: column;
41
+ min-block-size: 8em;
42
+
43
+ & > b { flex: 0 0 auto }
44
+ & > div { border: 1px solid; flex: 1; min-block-size: 0; overflow: auto; padding: .57em }
45
+ }
46
+ /* Added and removed read faster as colour than as underline and strike, and mixing into the
47
+ page's own background keeps them legible in a light and a dark chrome alike. */
48
+ & ins { background: color-mix(in srgb, green 30%, transparent); text-decoration: none }
49
+ & del { background: color-mix(in srgb, red 25%, transparent) }
50
+ & menu {
51
+ display: flex;
52
+ gap: .57em;
53
+ justify-content: flex-end;
54
+ margin: 0;
55
+ padding: 0;
56
+ }
57
+ }
58
+ `;
59
+
60
+ // Optional assistant view: a prompt over the surface's content, the answer beside the original, and
61
+ // an apply that writes it back through the source path — so an answer is external input like any
62
+ // other and meets the sanitizer.
63
+ //
64
+ // What answers, in which language, at which cost is application policy, so `request` is required and
65
+ // carries no default. `diff` is an enhancement: given one, the dialog shows a third pane comparing
66
+ // the original with the answer, which is otherwise left out rather than pulled in.
67
+ export function aiView({request, diff = null, prompts = [], label = 'Assistant', ...options} = {}) {
68
+ if (typeof request !== 'function') throw new TypeError('An assistant view requires a request function');
69
+ if (diff !== null && typeof diff !== 'function') throw new TypeError('An assistant diff must be a function');
70
+ if (!Array.isArray(prompts) || prompts.some(prompt => typeof prompt !== 'string')) {
71
+ throw new TypeError('Assistant prompts must be an array of strings');
72
+ }
73
+ if (typeof label !== 'string' || !label.trim()) throw new TypeError('An assistant label must be a non-empty string');
74
+ const suggestions = Object.freeze([...prompts]);
75
+ const editors = new WeakMap();
76
+ return Object.freeze({
77
+ name: 'ai',
78
+ setup({editor, root, chrome}) {
79
+ const state = {
80
+ chrome,
81
+ document: root.nodeType === Node.DOCUMENT_NODE ? root : root.ownerDocument,
82
+ options,
83
+ request,
84
+ diff,
85
+ suggestions,
86
+ label: label.trim(),
87
+ dialog: null,
88
+ pending: null,
89
+ };
90
+ editors.set(editor, state);
91
+ let connected = true;
92
+ return {dispose() {
93
+ if (!connected) return;
94
+ state.dialog?.remove();
95
+ editors.delete(editor);
96
+ connected = false;
97
+ }};
98
+ },
99
+ commands({editor, surface}) {
100
+ const state = editors.get(editor);
101
+ if (!state) throw new DOMException('The assistant extension is not set up', 'InvalidStateError');
102
+ return {ai: {
103
+ shortcut: 'ctrl+m',
104
+ // The dialog edits nothing by itself; applying runs as its own transaction.
105
+ transaction: false,
106
+ enabled: edit => !!edit.range,
107
+ run: () => show(state, surface),
108
+ }};
109
+ },
110
+ toolbar: Object.freeze([Object.freeze({command: 'ai', label, text: '✦', shortcut: 'ctrl+m'})]),
111
+ });
112
+ }
113
+
114
+ function show(state, surface) {
115
+ const view = new Source(surface, state.options);
116
+ const dialog = build(state);
117
+ const {html} = view.read();
118
+ const parts = state.parts;
119
+ parts.original.innerHTML = html;
120
+ parts.answer.innerHTML = '';
121
+ if (parts.diff) parts.diff.innerHTML = '';
122
+ parts.apply.disabled = true;
123
+ parts.prompt.value = '';
124
+ state.pending = {view, surface, html, run: 0};
125
+ dialog.showModal();
126
+ parts.prompt.focus();
127
+ return view;
128
+ }
129
+
130
+
131
+ // Every request carries the run it belongs to: an answer that arrives after the next prompt, or
132
+ // after the dialog was closed, has nothing left to fill in.
133
+ async function ask(state) {
134
+ const pending = state.pending;
135
+ const parts = state.parts;
136
+ if (!pending) return;
137
+ const run = ++pending.run;
138
+ const prompt = parts.prompt.value.trim();
139
+ if (!prompt) return;
140
+ parts.busy.disabled = true;
141
+ parts.apply.disabled = true;
142
+ try {
143
+ // The surface comes along, not a digest of it: what the field allows (`config`) decides what
144
+ // an answer may use, and what else it says about itself is the application's to read.
145
+ const answer = await state.request({prompt, html: pending.html, surface: pending.surface});
146
+ if (state.pending !== pending || pending.run !== run) return;
147
+ parts.answer.innerHTML = String(answer ?? '');
148
+ parts.apply.disabled = false;
149
+ await update(state, pending, run);
150
+ } catch (error) {
151
+ if (state.pending === pending && pending.run === run) parts.answer.textContent = String(error?.message ?? error);
152
+ } finally {
153
+ if (state.pending === pending && pending.run === run) parts.busy.disabled = false;
154
+ }
155
+ }
156
+
157
+ async function update(state, pending, run) {
158
+ if (!state.diff || !state.parts.diff) return;
159
+ const marked = await state.diff(pending.html, state.parts.answer.innerHTML);
160
+ if (state.pending === pending && pending.run === run) state.parts.diff.innerHTML = String(marked ?? '');
161
+ }
162
+
163
+ function build(state) {
164
+ if (state.dialog) return state.dialog;
165
+ const create = (tag, properties) => Object.assign(state.document.createElement(tag), properties);
166
+ const dialog = state.chrome.part('ai', STYLE, 'dialog');
167
+ dialog.setAttribute('aria-label', state.label);
168
+ const form = create('form', {method: 'dialog'});
169
+
170
+ const prompt = create('input', {name: 'prompt', autocomplete: 'off'});
171
+ prompt.setAttribute('aria-label', state.label);
172
+ const busy = create('fieldset', {className: 'prompt'});
173
+ busy.append(prompt, create('button', {type: 'button', textContent: 'Prompt', onclick: () => ask(state)}));
174
+ if (state.suggestions.length) {
175
+ const list = create('datalist', {id: 'ai-prompts'});
176
+ for (const value of state.suggestions) list.append(create('option', {value}));
177
+ prompt.setAttribute('list', list.id);
178
+ busy.append(list);
179
+ }
180
+ // Enter in the field asks rather than submitting the dialog, which would close it unanswered.
181
+ prompt.addEventListener('keydown', event => {
182
+ if (event.key !== 'Enter') return;
183
+ event.preventDefault();
184
+ ask(state);
185
+ });
186
+
187
+ const panes = create('div', {className: 'panes'});
188
+ const pane = name => {
189
+ const box = create('div', {className: 'pane'});
190
+ const body = create('div', {});
191
+ box.append(create('b', {textContent: name}), body);
192
+ panes.append(box);
193
+ return body;
194
+ };
195
+ const original = pane('Original');
196
+ const answer = pane('Answer');
197
+ answer.contentEditable = 'true';
198
+ const diff = state.diff ? pane('Changes') : null;
199
+ answer.addEventListener('input', () => {
200
+ const pending = state.pending;
201
+ if (pending) update(state, pending, pending.run);
202
+ });
203
+
204
+ const menu = state.document.createElement('menu');
205
+ const apply = create('button', {type: 'submit', value: 'apply', textContent: 'Apply'});
206
+ menu.append(create('button', {type: 'submit', value: 'cancel', textContent: 'Cancel'}), apply);
207
+
208
+ form.append(busy, panes, menu);
209
+ dialog.append(form);
210
+ dialog.addEventListener('close', () => finish(state, dialog.returnValue));
211
+ state.chrome.root.append(dialog);
212
+ state.dialog = dialog;
213
+ state.parts = {prompt, busy, original, answer, diff, apply};
214
+ return dialog;
215
+ }
216
+
217
+ function finish(state, result) {
218
+ const pending = state.pending;
219
+ state.pending = null;
220
+ if (!pending?.surface.connected) return;
221
+ // Focus returns before writing, so the surface owns the selection the transaction captures.
222
+ pending.surface.element.focus();
223
+ if (result !== 'apply') {
224
+ pending.surface.restore();
225
+ return;
226
+ }
227
+ pending.view.write(state.parts.answer.innerHTML);
228
+ }
@@ -7,6 +7,31 @@ export const defaultBlockStyles = Object.freeze([
7
7
  Object.freeze({name: 'h3', label: 'Heading 3', selector: 'h3', tag: 'h3'}),
8
8
  ]);
9
9
 
10
+ // `Absatz(p), Lead(p.lead), Notiz(p[aria-label=note])` — the label names it, the selector says what
11
+ // it is. What follows the tag is written when the style is applied and taken off when another one
12
+ // replaces it, so a class or an attribute needs no code of its own.
13
+ export function declaredStyles(declared) {
14
+ return Object.entries(declared).map(([label, selector]) => {
15
+ const [tag] = selector.match(/^[a-z][a-z\d-]*/) ?? [];
16
+ const classes = [...selector.matchAll(/\.([_a-zA-Z][\w-]*)/g)].map(match => match[1]);
17
+ const attributes = [...selector.matchAll(/\[([a-z][\w-]*)(?:=([^\]\s]+))?\]/g)]
18
+ .map(match => [match[1], (match[2] ?? '').replace(/^["']|["']$/g, '')]);
19
+ return {
20
+ name: selector, label, selector, tag,
21
+ write: element => {
22
+ if (classes.length) element.classList.add(...classes);
23
+ for (const [name, value] of attributes) element.setAttribute(name, value);
24
+ },
25
+ clear: element => {
26
+ for (const [name] of attributes) element.removeAttribute(name);
27
+ if (!classes.length) return;
28
+ element.classList.remove(...classes);
29
+ if (!element.classList.length) element.removeAttribute('class'); // rather than an empty one
30
+ },
31
+ };
32
+ });
33
+ }
34
+
10
35
  export function blockStyles(styles = defaultBlockStyles, {
11
36
  name = 'blocks',
12
37
  control = 'block',
@@ -16,21 +41,24 @@ export function blockStyles(styles = defaultBlockStyles, {
16
41
  for (const [property, value] of Object.entries({name, control, command, label})) {
17
42
  if (typeof value !== 'string' || !value.trim()) throw new TypeError(`Block module ${property} must be a non-empty string`);
18
43
  }
19
- const policy = new BlockStyles(styles);
20
- const commands = Object.freeze({[command]: policy.command()});
21
- const options = Object.freeze(policy.styles.map(style => Object.freeze({
22
- value: style.name,
23
- label: style.label,
24
- })));
44
+ // A host may name its own styles; hosts that name the same ones share a policy, because reading
45
+ // a declaration is cheap and building the policy behind it is not.
46
+ const policies = new Map();
47
+ const resolve = surface => {
48
+ const declared = surface.config.blocks;
49
+ const key = declared ? JSON.stringify(declared) : '';
50
+ if (!policies.has(key)) policies.set(key, new BlockStyles(declared ? declaredStyles(declared) : styles));
51
+ return policies.get(key);
52
+ };
25
53
  return Object.freeze({
26
54
  name: name.trim(),
27
- commands: () => commands,
55
+ commands: ({surface}) => ({[command]: resolve(surface).command()}),
28
56
  toolbar: Object.freeze([Object.freeze({
29
57
  type: 'select',
30
58
  name: control.trim(),
31
59
  command: command.trim(),
32
60
  label,
33
- options,
61
+ options: surface => resolve(surface).styles.map(style => ({value: style.name, label: style.label})),
34
62
  })]),
35
63
  });
36
64
  }