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 +6 -7
- package/js/rte/PLAN.md +65 -3
- package/js/rte/README.md +1 -0
- package/js/rte/ai.js +4 -0
- package/js/rte/playground/index.html +52 -14
- package/js/rte/src/client/README.md +65 -3
- package/js/rte/src/client/ai.js +228 -0
- package/js/rte/src/client/blocks.js +36 -8
- package/js/rte/src/client/classes.js +36 -27
- package/js/rte/src/client/contextual.js +6 -1
- package/js/rte/src/client/editor.js +130 -0
- package/js/rte/src/client/link.js +37 -8
- package/js/rte/src/command/block-style.js +11 -1
- package/js/rte/src/command/commands.js +1 -2
- package/js/rte/src/command/edit.js +12 -2
- package/js/rte/src/command/enter.js +6 -1
- package/js/rte/src/config/README.md +30 -3
- package/js/rte/src/config/config.js +49 -11
- package/js/rte/src/core/core.js +3 -1
- package/js/rte/src/input/input-pipeline.js +6 -4
- package/js/rte/src/sanitize/README.md +26 -8
- package/js/rte/src/sanitize/native.js +2 -0
- package/js/rte/src/sanitize/policy.js +38 -8
- package/js/rte/src/source/source.js +4 -1
- package/js/rte/src/surface/surface.js +10 -1
- package/js/rte/src/ui/README.md +5 -0
- package/js/rte/src/ui/caret.js +19 -1
- package/js/rte/src/ui/toolbar.js +64 -7
- package/package.json +1 -1
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) {
|
|
139
|
-
|
|
140
|
-
const svgDoc =
|
|
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
|
|
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 `
|
|
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
|
|
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
|
@@ -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
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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><strong></code> and <code><b></code>,
|
|
360
372
|
creates canonical <code><strong></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
|
-
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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: () =>
|
|
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
|
}
|