u2ui 1.5.2 → 1.5.4

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