u2ui 1.5.3 → 1.5.7

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.
Files changed (146) hide show
  1. package/el/ico/ico.js +5 -5
  2. package/el/rte/README.md +53 -13
  3. package/el/rte/manifest.json +19 -4
  4. package/el/rte/rte.css +31 -6
  5. package/el/rte/rte.js +174 -44
  6. package/el/rte0/README.md +37 -0
  7. package/el/rte0/manifest.json +33 -0
  8. package/el/rte0/rte0.css +9 -0
  9. package/el/rte0/rte0.js +56 -0
  10. package/el/system/styler.js +2 -0
  11. package/el/tooltip/tooltip.css +2 -2
  12. package/js/Placer/Placer.js +2 -1
  13. package/js/rte/AGENTS.md +142 -0
  14. package/js/rte/PLAN.md +487 -0
  15. package/js/rte/README.md +319 -25
  16. package/js/rte/blocks.js +7 -0
  17. package/js/rte/breaks.js +6 -0
  18. package/js/rte/classes.js +7 -0
  19. package/js/rte/docs/usage.md +845 -0
  20. package/js/rte/images.js +7 -0
  21. package/js/rte/link.js +8 -0
  22. package/js/rte/playground/README.md +95 -0
  23. package/js/rte/playground/hosts.html +553 -0
  24. package/js/rte/playground/index.html +835 -0
  25. package/js/rte/rte.js +56 -5
  26. package/js/rte/run.out +17 -0
  27. package/js/rte/source.js +12 -0
  28. package/js/rte/src/browser/README.md +40 -0
  29. package/js/rte/src/browser/interactive.js +17 -0
  30. package/js/rte/src/browser/range-rect.js +82 -0
  31. package/js/rte/src/client/README.md +385 -0
  32. package/js/rte/src/client/blocks.js +38 -0
  33. package/js/rte/src/client/breaks.js +212 -0
  34. package/js/rte/src/client/classes.js +48 -0
  35. package/js/rte/src/client/editor.js +472 -0
  36. package/js/rte/src/client/external-input.js +47 -0
  37. package/js/rte/src/client/history.js +13 -0
  38. package/js/rte/src/client/images.js +285 -0
  39. package/js/rte/src/client/link.js +567 -0
  40. package/js/rte/src/client/marks.js +25 -0
  41. package/js/rte/src/client/source.js +182 -0
  42. package/js/rte/src/client/structure.js +27 -0
  43. package/js/rte/src/client/tables.js +165 -0
  44. package/js/rte/src/client/unstyle.js +32 -0
  45. package/js/rte/src/command/README.md +520 -0
  46. package/js/rte/src/command/block-boundary.js +43 -0
  47. package/js/rte/src/command/block-style.js +120 -0
  48. package/js/rte/src/command/commands.js +159 -0
  49. package/js/rte/src/command/delete.js +119 -0
  50. package/js/rte/src/command/edit.js +78 -0
  51. package/js/rte/src/command/element.js +45 -0
  52. package/js/rte/src/command/enter.js +175 -0
  53. package/js/rte/src/command/fragment.js +76 -0
  54. package/js/rte/src/command/insert.js +52 -0
  55. package/js/rte/src/command/list.js +284 -0
  56. package/js/rte/src/command/mark.js +583 -0
  57. package/js/rte/src/command/pending-marks.js +216 -0
  58. package/js/rte/src/command/table.js +207 -0
  59. package/js/rte/src/command/unstyle.js +248 -0
  60. package/js/rte/src/config/README.md +96 -0
  61. package/js/rte/src/config/config.js +134 -0
  62. package/js/rte/src/core/README.md +63 -0
  63. package/js/rte/src/core/core.js +247 -0
  64. package/js/rte/src/history/README.md +83 -0
  65. package/js/rte/src/history/history.js +283 -0
  66. package/js/rte/src/input/README.md +173 -0
  67. package/js/rte/src/input/external-input.js +130 -0
  68. package/js/rte/src/input/input-pipeline.js +459 -0
  69. package/js/rte/src/mark/README.md +219 -0
  70. package/js/rte/src/mark/dom-adapter.js +100 -0
  71. package/js/rte/src/mark/mark.js +133 -0
  72. package/js/rte/src/mark/standard.js +76 -0
  73. package/js/rte/src/model/README.md +71 -0
  74. package/js/rte/src/model/content-model.js +213 -0
  75. package/js/rte/src/model/html/README.md +51 -0
  76. package/js/rte/src/model/html/html-model.js +90 -0
  77. package/js/rte/src/normalize/execute/README.md +33 -0
  78. package/js/rte/src/normalize/execute/repair-executor.js +129 -0
  79. package/js/rte/src/normalize/normalizer/README.md +48 -0
  80. package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
  81. package/js/rte/src/normalize/repair/README.md +70 -0
  82. package/js/rte/src/normalize/repair/repair-planner.js +208 -0
  83. package/js/rte/src/sanitize/README.md +108 -0
  84. package/js/rte/src/sanitize/native.js +43 -0
  85. package/js/rte/src/sanitize/policy.js +206 -0
  86. package/js/rte/src/selection/README.md +39 -0
  87. package/js/rte/src/selection/map/README.md +53 -0
  88. package/js/rte/src/selection/map/point-map.js +307 -0
  89. package/js/rte/src/selection/ownership/README.md +32 -0
  90. package/js/rte/src/selection/ownership/ownership.js +42 -0
  91. package/js/rte/src/selection/point/README.md +34 -0
  92. package/js/rte/src/selection/point/point.js +83 -0
  93. package/js/rte/src/selection/range/README.md +38 -0
  94. package/js/rte/src/selection/range/edit-range.js +223 -0
  95. package/js/rte/src/selection/snapshot.js +64 -0
  96. package/js/rte/src/source/README.md +64 -0
  97. package/js/rte/src/source/source.js +193 -0
  98. package/js/rte/src/surface/README.md +36 -0
  99. package/js/rte/src/surface/content.js +16 -0
  100. package/js/rte/src/surface/surface.js +87 -0
  101. package/js/rte/src/transaction/README.md +35 -0
  102. package/js/rte/src/transaction/transaction.js +56 -0
  103. package/js/rte/src/ui/README.md +271 -0
  104. package/js/rte/src/ui/caret.js +17 -0
  105. package/js/rte/src/ui/chrome.js +214 -0
  106. package/js/rte/src/ui/handles.js +179 -0
  107. package/js/rte/src/ui/highlight.js +86 -0
  108. package/js/rte/src/ui/place.js +25 -0
  109. package/js/rte/src/ui/toolbar.js +258 -0
  110. package/js/rte/src/unstyle/README.md +103 -0
  111. package/js/rte/src/unstyle/unstyle.js +140 -0
  112. package/js/rte/tables.js +7 -0
  113. package/js/rte/unstyle.js +8 -0
  114. package/js/rte0/README.md +37 -0
  115. package/js/rte0/rte.js +7 -0
  116. package/js/{rte → rte0}/src/behavior.js +0 -1
  117. package/js/{rte → rte0}/src/events.js +0 -1
  118. package/js/{rte → rte0}/src/range.js +0 -2
  119. package/js/{rte → rte0}/src/ui.items.js +0 -2
  120. package/js/{rte → rte0}/src/ui.js +0 -1
  121. package/package.json +1 -1
  122. package/u2/Element.js +2 -2
  123. package/u2/plan-scoped-refactoring.md +133 -303
  124. package/u2/projects.json +18 -2
  125. package/u2/tools/update.repos.json.js +1 -1
  126. package/el/qrcode/package.json +0 -22
  127. /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
  128. /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
  129. /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
  130. /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
  131. /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
  132. /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
  133. /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
  134. /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
  135. /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
  136. /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
  137. /package/js/{rte → rte0}/old/Rte/main.css +0 -0
  138. /package/js/{rte → rte0}/old/old.rte.js +0 -0
  139. /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
  140. /package/js/{rte → rte0}/src/fixes.js +0 -0
  141. /package/js/{rte → rte0}/src/rte.js +0 -0
  142. /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
  143. /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
  144. /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
  145. /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
  146. /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
@@ -0,0 +1,567 @@
1
+ import {inlineUi} from '../config/config.js';
2
+ import {linkHtml} from '../mark/standard.js';
3
+ import {caretAfter} from '../ui/caret.js';
4
+ import {place} from '../ui/place.js';
5
+ import {valueMark} from '../command/mark.js';
6
+
7
+ const STYLE = `
8
+ #link {
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: .23em;
12
+ padding: .34em;
13
+
14
+ label { align-items: center; display: flex; gap: .57em; justify-content: space-between; }
15
+ input[type=text] {
16
+ background: transparent;
17
+ border: 1px solid color-mix(in srgb, CanvasText 24%, transparent);
18
+ border-radius: .34em;
19
+ field-sizing: content;
20
+ min-inline-size: 16em;
21
+ max-inline-size: clamp(16em, 50vw, 32em);
22
+ padding: .17em .34em;
23
+ flex-grow: 1;
24
+ }
25
+ ul {
26
+ border: 1px solid color-mix(in srgb, CanvasText 24%, transparent);
27
+ border-radius: .34em;
28
+ cursor: default;
29
+ list-style: none;
30
+ margin: 0;
31
+ max-block-size: 40vh;
32
+ overflow: auto;
33
+ overscroll-behavior: contain;
34
+ padding: 0;
35
+ }
36
+ li { border-radius: .23em; padding: .17em .34em; }
37
+ #link-open { color: inherit; padding: 0 .17em; pointer-events: auto; text-decoration: none; }
38
+ li[aria-selected=true] { background: Highlight; color: HighlightText; }
39
+ }
40
+ `;
41
+
42
+ const LABELS = {href: 'Address', target: 'New tab', title: 'Title', rel: 'Rel'};
43
+
44
+ // Optional contextual link editor.
45
+ //
46
+ // The form is where the caret is: it appears on its own when the caret is in a
47
+ // link and goes when the caret leaves, the way the table and image handles do. It
48
+ // therefore never takes the focus by appearing — someone is typing — and the
49
+ // toolbar control is left with the one thing that is a decision: turning a
50
+ // selection into a link. Removing one is emptying its address.
51
+ //
52
+ // The command is an ordinary value mark, so creating, changing, and removing a
53
+ // link is one path that any other UI can drive; this module only supplies one.
54
+ // Which protocols and attributes are acceptable is the sanitizer's policy, not
55
+ // this form's: it writes what the link adapter understands and nothing else.
56
+ //
57
+ // What an address means, though, is the application's: `normalize` gets the
58
+ // finished value whenever a field is left, and may complete a bare domain, turn
59
+ // an address into a scheme of its own, or decide `rel` and `target` from it.
60
+ // `suggest` is asked what a new link should point at, given the text it is being
61
+ // put on, and `complete` what addresses go with what is being typed. All three
62
+ // default to leaving everything alone.
63
+ export function linkEditor({
64
+ fields = ['href', 'target', 'title'],
65
+ normalize = value => value,
66
+ suggest = null,
67
+ complete = null,
68
+ } = {}) {
69
+ if (!Array.isArray(fields) || !fields.length || fields.some(name => !LABELS[name])) {
70
+ throw new TypeError(`A link editor field must be one of ${Object.keys(LABELS).join(', ')}`);
71
+ }
72
+ if (typeof normalize !== 'function') throw new TypeError('A link normalizer must be a function');
73
+ for (const [property, value] of Object.entries({suggest, complete})) {
74
+ if (value !== null && typeof value !== 'function') throw new TypeError(`A link ${property} must be a function`);
75
+ }
76
+ const editors = new WeakMap();
77
+ return Object.freeze({
78
+ name: 'link',
79
+ setup({editor, root, chrome}) {
80
+ const state = {
81
+ root,
82
+ chrome,
83
+ document: root.nodeType === Node.DOCUMENT_NODE ? root : root.ownerDocument,
84
+ fields: [...fields],
85
+ normalize,
86
+ suggest,
87
+ complete,
88
+ completing: null,
89
+ views: new Map(),
90
+ form: null,
91
+ active: null,
92
+ dirty: false,
93
+ writing: false,
94
+ };
95
+ editors.set(editor, state);
96
+ let connected = true;
97
+ return {dispose() {
98
+ if (!connected) return;
99
+ state.form?.remove();
100
+ editors.delete(editor);
101
+ connected = false;
102
+ }};
103
+ },
104
+ commands({editor, surface, commands}) {
105
+ const state = editors.get(editor);
106
+ if (!state) throw new DOMException('The link extension is not set up', 'InvalidStateError');
107
+ const view = {surface, commands};
108
+ state.views.set(surface, view);
109
+ const link = valueMark(linkHtml);
110
+ return {
111
+ link,
112
+ // Neither creating nor reaching the form changes the document, so
113
+ // this needs no transaction. Creating needs text; at a link the
114
+ // form is already there and only wants the keyboard.
115
+ editLink: {
116
+ shortcut: 'ctrl+k',
117
+ transaction: false,
118
+ enabled: edit => !!edit.range && (!edit.range.collapsed || link.state(edit) !== null),
119
+ state: edit => link.state(edit) !== null,
120
+ run: edit => link.state(edit) !== null ? reach(state) : create(state, view),
121
+ },
122
+ };
123
+ },
124
+ attach({editor, surface}) {
125
+ const state = editors.get(editor);
126
+ if (!state) throw new DOMException('The link extension is not set up', 'InvalidStateError');
127
+ const controller = new state.document.defaultView.AbortController();
128
+ const listen = {signal: controller.signal};
129
+ surface.addEventListener('u2-rte-selectionchange', () => follow(state, surface), listen);
130
+ surface.addEventListener('u2-rte-change', () => follow(state, surface), listen);
131
+ // What a session's end takes away, its beginning brings back: coming
132
+ // back to the same selection is no selection change to hear about.
133
+ surface.addEventListener('u2-rte-activate', () => follow(state, surface), listen);
134
+ surface.addEventListener('u2-rte-deactivate', () => close(state, surface), listen);
135
+ return {dispose() {
136
+ controller.abort();
137
+ close(state, surface);
138
+ state.views.delete(surface);
139
+ }};
140
+ },
141
+ toolbar: Object.freeze([
142
+ Object.freeze({command: 'editLink', name: 'link', label: 'Link', text: '↗', state: true, shortcut: 'ctrl+k'}),
143
+ ]),
144
+ });
145
+ }
146
+
147
+ export const link = linkEditor();
148
+
149
+ // An edit the form has not applied yet, applied now. Marking a link selects it,
150
+ // which is right while someone is typing in the form and wrong once they have
151
+ // moved on — so whatever the selection has become is put back afterwards.
152
+ function settle(state) {
153
+ const active = state.active;
154
+ if (!state.dirty || state.closing || !active?.surface.connected) return null;
155
+ const selection = active.surface.core.selection;
156
+ const keep = selection.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
157
+ state.closing = true;
158
+ try { commit(state); } finally { state.closing = false; }
159
+ if (!keep) return null;
160
+ selection.removeAllRanges();
161
+ selection.addRange(keep);
162
+ if (active.surface.element.contains(keep.startContainer)) active.surface.capture();
163
+ return null;
164
+ }
165
+
166
+ // The form for one link, or for a selection about to become one. Appearing is not
167
+ // a reason to take the focus: only asking for it is.
168
+ function show(state, view, element, focus = false) {
169
+ settle(state);
170
+ const form = build(state);
171
+ const surface = view.surface;
172
+ // What this form edits, once: an existing link is its own contents, a new one
173
+ // is the selection it was started on. Writing to "wherever the caret is" would
174
+ // put the address somewhere else the moment the caret moved.
175
+ state.active = {view, surface, element, selection: surface.selection, creating: !element,
176
+ range: element ? contents(element) : surface.selection?.range() || null};
177
+ state.dirty = false;
178
+ const value = view.commands.state('link');
179
+ fill(state, value && value !== 'mixed' ? value : null);
180
+ form.hidden = false;
181
+ position(state);
182
+ if (focus) form.querySelector('[name=href]')?.focus();
183
+ return form;
184
+ }
185
+
186
+ // Turning a selection into a link: the form opens empty, with the keyboard in it.
187
+ function create(state, view) {
188
+ const active = state.active;
189
+ if (active?.creating && active.surface === view.surface) return reach(state);
190
+ show(state, view, null, true);
191
+ if (state.suggest) propose(state, state.active);
192
+ return state.form;
193
+ }
194
+
195
+ // The form is already showing; the keyboard wants to be in it.
196
+ function reach(state) {
197
+ const field = state.form?.querySelector('[name=href]');
198
+ field?.focus();
199
+ field?.select();
200
+ return state.form || null;
201
+ }
202
+
203
+ // A new link may be asked what it should point at. The answer can take a round
204
+ // trip, so it counts only while the form is still open on the same link and the
205
+ // address is still the empty one it was asked about.
206
+ //
207
+ // It is offered, not applied: writing it would mark the link, and marking moves
208
+ // the selection into it and the focus after that — throwing whoever asked for a
209
+ // link straight back into the text. It stands in the field, selected so one
210
+ // keystroke replaces it, and becomes the link when the form is done.
211
+ async function propose(state, active) {
212
+ const suggested = await state.suggest(target(active.surface)?.toString() || '', active.surface);
213
+ if (state.active !== active || read(state)) return null;
214
+ if (!suggested?.href) return null;
215
+ fill(state, suggested);
216
+ state.dirty = true;
217
+ state.form.querySelector('[name=href]').select();
218
+ return suggested;
219
+ }
220
+
221
+ // Addresses that go with what is being typed. One question is in flight at a time
222
+ // and a late answer to an older word is dropped, so what the list offers always
223
+ // belongs to what the field says.
224
+ //
225
+ // The list is the form's own rather than a native datalist: an entry may bring
226
+ // markup — a title over its path, a thumbnail — which a datalist cannot show. That
227
+ // markup comes from the application but is written with `setHTML()`, so the
228
+ // platform sanitizes it like any other imported html.
229
+ function offer(state, text) {
230
+ const list = state.form?.querySelector('ul');
231
+ if (!list) return null;
232
+ clearTimeout(state.completing);
233
+ if (!text.trim()) return options(state, []);
234
+ state.completing = setTimeout(async () => {
235
+ const found = await state.complete(text, state.active?.surface) || [];
236
+ if (state.form.querySelector('[name=href]').value !== text) return;
237
+ options(state, found);
238
+ }, 150);
239
+ return null;
240
+ }
241
+
242
+ function options(state, entries) {
243
+ const list = state.form.querySelector('ul');
244
+ list.replaceChildren(...entries.map(entry => {
245
+ const option = state.document.createElement('li');
246
+ option.role = 'option';
247
+ option.dataset.value = entry.value ?? entry;
248
+ const markup = typeof entry === 'object' ? entry.html : null;
249
+ if (markup && option.setHTML) option.setHTML(markup);
250
+ else option.textContent = entry.label ?? entry.value ?? entry;
251
+ return option;
252
+ }));
253
+ list.hidden = !entries.length;
254
+ state.form.querySelector('[name=href]').ariaExpanded = String(!list.hidden);
255
+ return mark(state, list.firstElementChild);
256
+ }
257
+
258
+ // One entry is current, and the field says which: a list nobody can point at with
259
+ // the keyboard is only half a control.
260
+ function mark(state, option) {
261
+ const list = state.form.querySelector('ul');
262
+ for (const item of list.children) item.ariaSelected = String(item === option);
263
+ state.form.querySelector('[name=href]').setAttribute('aria-activedescendant', option ? id(option) : '');
264
+ option?.scrollIntoView({block: 'nearest'});
265
+ return option || null;
266
+ }
267
+
268
+ function id(option) {
269
+ option.id ||= `u2-rte-link-option-${[...option.parentNode.children].indexOf(option)}`;
270
+ return option.id;
271
+ }
272
+
273
+ function chosen(state) {
274
+ return state.form?.querySelector('li[aria-selected=true]') || null;
275
+ }
276
+
277
+ // Taking an entry is typing it: the field says what was chosen and the link
278
+ // follows, exactly as if it had been typed out.
279
+ function take(state, option) {
280
+ if (!option) return null;
281
+ const field = state.form.querySelector('[name=href]');
282
+ field.value = option.dataset.value;
283
+ options(state, []);
284
+ field.focus();
285
+ return commit(state, field);
286
+ }
287
+
288
+ function completions(state, form) {
289
+ const list = state.document.createElement('ul');
290
+ list.role = 'listbox';
291
+ list.hidden = true;
292
+ list.id = 'link-completions';
293
+ list.addEventListener('mousedown', event => event.preventDefault()); // keep the field's focus
294
+ list.addEventListener('click', event => take(state, event.target.closest('li')));
295
+ list.addEventListener('mouseover', event => {
296
+ const option = event.target.closest('li');
297
+ if (option) mark(state, option);
298
+ });
299
+ const field = form.querySelector('[name=href]');
300
+ field.role = 'combobox';
301
+ field.ariaExpanded = 'false';
302
+ field.ariaAutoComplete = 'list';
303
+ field.setAttribute('aria-controls', list.id);
304
+ return list;
305
+ }
306
+
307
+ // While the list is open it owns the keys that move and choose in it.
308
+ function listKey(state, event) {
309
+ const list = state.form.querySelector('ul');
310
+ if (!list || list.hidden) return false;
311
+ const current = chosen(state);
312
+ const step = {ArrowDown: 'nextElementSibling', ArrowUp: 'previousElementSibling'}[event.key];
313
+ if (step) {
314
+ mark(state, current?.[step] || (event.key === 'ArrowDown' ? list.firstElementChild : list.lastElementChild));
315
+ return true;
316
+ }
317
+ if (event.key === 'Enter' && current) return !!take(state, current);
318
+ if (event.key === 'Escape') return !!options(state, []) || true;
319
+ return false;
320
+ }
321
+
322
+ // What the fields say, as the application reads it. This happens once, when the
323
+ // form is done: normalizing while a field is being typed into would rewrite
324
+ // half-typed addresses under the caret, and the form cannot wait for a field to be
325
+ // left either — marking the link takes the focus away and gives it back on every
326
+ // keystroke, so "left behind" is not a thing here.
327
+ function commit(state, field = null) {
328
+ // Once: a second run would mark the link again and leave it selected, over
329
+ // whatever caret the first one settled on.
330
+ state.dirty = false;
331
+ const typed = read(state);
332
+ const value = state.normalize(typed, state.active?.surface) ?? null;
333
+ if (value !== typed) fill(state, value);
334
+ return write(state, value, field);
335
+ }
336
+
337
+ // Every edit is applied as it is made. Marking the run as ongoing input keeps
338
+ // history from recording a step per keystroke.
339
+ function write(state, value, field = null) {
340
+ const active = state.active;
341
+ if (!active?.surface.connected) return null;
342
+ const range = active.element ? contents(active.element) : active.range;
343
+ if (!range) return close(state);
344
+ // Marking the link moves the document selection into it, and an engine
345
+ // follows that with focus. Whoever is being typed into gets it back, caret
346
+ // and all, or the next character would land in the editor.
347
+ const caret = field && typeof field.selectionStart === 'number'
348
+ ? [field.selectionStart, field.selectionEnd]
349
+ : null;
350
+ state.writing = true;
351
+ try {
352
+ active.view.commands.run('link', {value, range, trigger: 'input'});
353
+ } finally {
354
+ state.writing = false;
355
+ }
356
+ if (field) {
357
+ field.focus();
358
+ if (caret) field.setSelectionRange(...caret);
359
+ }
360
+ // What the form edits from here on is whatever the run left behind: the link
361
+ // it made, or — when emptying the address took one away — the text that is
362
+ // left, so the same words can be linked again.
363
+ active.element = marked(active.surface, at(active.surface));
364
+ active.range = active.element ? contents(active.element)
365
+ : active.surface.selection?.range() || active.range;
366
+ active.selection = active.surface.selection;
367
+ if (!active.element && value) return close(state);
368
+ position(state);
369
+ return active.element;
370
+ }
371
+
372
+ // What is left behind decides where the caret goes: the link that was made, or
373
+ // the text that is left when emptying the address took one away.
374
+ function leave(state) {
375
+ const active = state.active;
376
+ const element = active?.element;
377
+ if (active?.creating) close(state);
378
+ else if (state.dirty) commit(state);
379
+ if (!active?.surface.connected) return null;
380
+ caretAfter(active.surface, state.active?.element || element);
381
+ return null;
382
+ }
383
+
384
+ function close(state, surface = null) {
385
+ if (!state.active || surface && state.active.surface !== surface) return;
386
+ settle(state);
387
+ clearTimeout(state.completing);
388
+ if (state.form?.querySelector('ul')) options(state, []);
389
+ state.active = null;
390
+ if (state.form) state.form.hidden = true;
391
+ return null;
392
+ }
393
+
394
+ // Where the caret is decides: in a link the form is there, outside it is not. The
395
+ // one state that is not a place is creating — that selection is not a link yet,
396
+ // and the form holds until it is or the selection moves on.
397
+ function follow(state, surface) {
398
+ const active = state.active;
399
+ if (state.writing || active && active.surface !== surface) return;
400
+ if (!surface.connected || !inlineUi(surface.config, 'link')) return close(state, surface);
401
+ const element = marked(surface, at(surface));
402
+ if (element) {
403
+ if (active?.element === element) return position(state);
404
+ return show(state, view(state, surface), element);
405
+ }
406
+ if (active?.creating && surface.selection === active.selection) return position(state);
407
+ return close(state, surface);
408
+ }
409
+
410
+ function view(state, surface) {
411
+ return state.views.get(surface);
412
+ }
413
+
414
+ // Close to what it edits: the link itself once there is one, the selection while
415
+ // there is not.
416
+ function position(state) {
417
+ const active = state.active;
418
+ if (!active || !state.form) return false;
419
+ return place(state.form, active.surface, {
420
+ align: 'start',
421
+ prefer: 'below',
422
+ gap: 4,
423
+ on: active.element?.getBoundingClientRect() || null,
424
+ });
425
+ }
426
+
427
+ // The node the surface's saved selection starts in.
428
+ function at(surface) {
429
+ return surface.selection?.range().startContainer || null;
430
+ }
431
+
432
+ // The contents of one element, as a range.
433
+ function contents(element) {
434
+ const range = element.ownerDocument.createRange();
435
+ range.selectNodeContents(element);
436
+ return range;
437
+ }
438
+
439
+ // A caret inside a link edits the whole link; a selection marks exactly itself.
440
+ function target(surface) {
441
+ const range = surface.selection?.range();
442
+ if (!range || !range.collapsed) return range || null;
443
+ const element = marked(surface, range.startContainer);
444
+ if (!element) return range;
445
+ const whole = surface.element.ownerDocument.createRange();
446
+ whole.selectNodeContents(element);
447
+ return whole;
448
+ }
449
+
450
+ function marked(surface, node) {
451
+ let element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
452
+ for (; element && element !== surface.element; element = element.parentElement) {
453
+ if (linkHtml.parse(element)) return element;
454
+ }
455
+ return null;
456
+ }
457
+
458
+ function build(state) {
459
+ if (state.form) return state.form;
460
+ const form = state.chrome.part('link', STYLE, 'form');
461
+ form.className = 'panel';
462
+ form.noValidate = true;
463
+ form.hidden = true;
464
+ form.setAttribute('aria-label', 'Link');
465
+ for (const name of state.fields) form.append(field(state.document, name));
466
+ if (state.complete) form.append(completions(state, form));
467
+ // No Apply and no Remove: what the fields say is what the link is, as it is
468
+ // typed, and an emptied address says there is no link.
469
+ form.addEventListener('input', event => {
470
+ state.dirty = true;
471
+ if (state.complete && event.target.name === 'href') offer(state, event.target.value);
472
+ write(state, read(state), event.target);
473
+ openable(state);
474
+ });
475
+ form.addEventListener('submit', event => {
476
+ event.preventDefault();
477
+ leave(state);
478
+ });
479
+ // Two text fields block implicit submission, so the form owns its own keys.
480
+ // With every edit already applied, Enter has nothing left to confirm and
481
+ // Escape nothing to undo: both hand the caret back to the text.
482
+ form.addEventListener('keydown', event => {
483
+ if (state.complete && listKey(state, event)) return event.preventDefault();
484
+ if (event.key !== 'Escape' && event.key !== 'Enter') return;
485
+ event.preventDefault();
486
+ leave(state);
487
+ });
488
+ state.chrome.root.append(form);
489
+ state.form = form;
490
+ return form;
491
+ }
492
+
493
+ // The address is a plain text field on purpose. Native url validation rejects
494
+ // relative paths, fragments, and application schemes, and would silently block
495
+ // the form; which protocols are acceptable is the sanitizer's decision.
496
+ function field(document, name) {
497
+ const label = document.createElement('label');
498
+ const input = document.createElement('input');
499
+ input.name = name;
500
+ input.type = name === 'target' ? 'checkbox' : 'text';
501
+ if (name === 'href') {
502
+ input.inputMode = 'url';
503
+ input.autocomplete = 'url';
504
+ input.spellcheck = false;
505
+ }
506
+ label.append(LABELS[name], input);
507
+ // A way to see where the address actually goes, without leaving the editor.
508
+ if (name === 'href') label.append(opener(document));
509
+ return label;
510
+ }
511
+
512
+ function opener(document) {
513
+ const open = document.createElement('a');
514
+ open.id = 'link-open';
515
+ open.target = '_blank';
516
+ open.rel = 'noopener noreferrer';
517
+ open.textContent = '↗';
518
+ open.title = 'Open in a new tab';
519
+ open.hidden = true;
520
+ return open;
521
+ }
522
+
523
+ // Whatever the field is given — a suggestion, the link the caret walked into, a
524
+ // normalized address — is asked about too. An address is an identifier; the list
525
+ // is where it says what it stands for.
526
+ function fill(state, value) {
527
+ for (const name of state.fields) {
528
+ const input = state.form.querySelector(`[name=${name}]`);
529
+ if (input.type === 'checkbox') input.checked = value?.target === '_blank';
530
+ else input.value = value?.[name] || '';
531
+ }
532
+ if (state.complete) offer(state, value?.href || '');
533
+ return openable(state);
534
+ }
535
+
536
+ // The address, as somewhere the browser can actually go. An application scheme —
537
+ // a cms page id, a record reference — is a link the editor understands and the
538
+ // browser does not, so there is nothing to offer for it.
539
+ const NAVIGABLE = new Set(['http:', 'https:', 'mailto:', 'tel:']);
540
+
541
+ function openable(state) {
542
+ const open = state.form?.querySelector('#link-open');
543
+ if (!open) return null;
544
+ const typed = state.form.querySelector('[name=href]').value.trim();
545
+ let href = null;
546
+ try {
547
+ if (typed) href = NAVIGABLE.has(new URL(typed, state.document.baseURI).protocol) ? typed : null;
548
+ } catch { /* half an address is not one yet */ }
549
+ open.hidden = !href;
550
+ if (href) open.href = href;
551
+ return href;
552
+ }
553
+
554
+ function read(state) {
555
+ const value = {};
556
+ for (const name of state.fields) {
557
+ const input = state.form.querySelector(`[name=${name}]`);
558
+ if (input.type === 'checkbox') {
559
+ if (input.checked) value.target = '_blank';
560
+ } else if (input.value.trim()) {
561
+ value[name] = input.value.trim();
562
+ }
563
+ }
564
+ return value.href ? value : null;
565
+ }
566
+
567
+
@@ -0,0 +1,25 @@
1
+ import {boldHtml, codeHtml, italicHtml, strikeHtml, underlineHtml} from '../mark/standard.js';
2
+
3
+ // The ready-made inline marks as one convention module. Every control goes
4
+ // through the surface's shared `PendingMarks`, so a caret toggle formats the
5
+ // next ordinary text input instead of installing a competing input route.
6
+ const MARKS = [
7
+ {name: 'bold', adapter: boldHtml, label: 'Bold', text: 'B', shortcut: 'ctrl+b'},
8
+ {name: 'italic', adapter: italicHtml, label: 'Italic', text: 'I', shortcut: 'ctrl+i'},
9
+ {name: 'underline', adapter: underlineHtml, label: 'Underline', text: 'U', shortcut: 'ctrl+u'},
10
+ {name: 'strike', adapter: strikeHtml, label: 'Strikethrough', text: 'S', shortcut: 'ctrl+shift+x'},
11
+ {name: 'code', adapter: codeHtml, label: 'Code', text: '‹›', shortcut: 'ctrl+e'},
12
+ ];
13
+
14
+ export const marks = Object.freeze({
15
+ name: 'marks',
16
+ commands: ({pending}) => Object.fromEntries(MARKS.map(mark =>
17
+ [mark.name, {...pending.toggle(mark.adapter), shortcut: mark.shortcut}])),
18
+ toolbar: Object.freeze(MARKS.map(mark => Object.freeze({
19
+ command: mark.name,
20
+ label: mark.label,
21
+ text: mark.text,
22
+ state: true,
23
+ shortcut: mark.shortcut,
24
+ }))),
25
+ });