u2ui 1.5.3 → 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.
- package/el/ico/ico.js +5 -5
- package/el/rte/README.md +53 -13
- package/el/rte/manifest.json +19 -4
- package/el/rte/rte.css +31 -6
- package/el/rte/rte.js +174 -44
- package/el/rte0/README.md +37 -0
- package/el/rte0/manifest.json +33 -0
- package/el/rte0/rte0.css +9 -0
- package/el/rte0/rte0.js +56 -0
- package/el/system/styler.js +2 -0
- package/el/tooltip/tooltip.css +2 -2
- package/js/Placer/Placer.js +2 -1
- package/js/rte/AGENTS.md +142 -0
- package/js/rte/PLAN.md +488 -0
- package/js/rte/README.md +319 -25
- package/js/rte/blocks.js +7 -0
- package/js/rte/breaks.js +6 -0
- package/js/rte/classes.js +7 -0
- package/js/rte/docs/usage.md +845 -0
- package/js/rte/images.js +7 -0
- package/js/rte/link.js +8 -0
- package/js/rte/playground/README.md +95 -0
- package/js/rte/playground/hosts.html +518 -0
- package/js/rte/playground/index.html +824 -0
- package/js/rte/rte.js +56 -5
- package/js/rte/source.js +12 -0
- package/js/rte/src/browser/README.md +40 -0
- package/js/rte/src/browser/range-rect.js +82 -0
- package/js/rte/src/client/README.md +378 -0
- package/js/rte/src/client/blocks.js +38 -0
- package/js/rte/src/client/breaks.js +212 -0
- package/js/rte/src/client/classes.js +48 -0
- package/js/rte/src/client/editor.js +461 -0
- package/js/rte/src/client/external-input.js +47 -0
- package/js/rte/src/client/history.js +13 -0
- package/js/rte/src/client/images.js +275 -0
- package/js/rte/src/client/link.js +572 -0
- package/js/rte/src/client/marks.js +25 -0
- package/js/rte/src/client/source.js +182 -0
- package/js/rte/src/client/structure.js +27 -0
- package/js/rte/src/client/tables.js +162 -0
- package/js/rte/src/client/unstyle.js +32 -0
- package/js/rte/src/command/README.md +517 -0
- package/js/rte/src/command/block-boundary.js +43 -0
- package/js/rte/src/command/block-style.js +120 -0
- package/js/rte/src/command/commands.js +158 -0
- package/js/rte/src/command/delete.js +112 -0
- package/js/rte/src/command/edit.js +72 -0
- package/js/rte/src/command/element.js +45 -0
- package/js/rte/src/command/enter.js +175 -0
- package/js/rte/src/command/fragment.js +76 -0
- package/js/rte/src/command/insert.js +52 -0
- package/js/rte/src/command/list.js +284 -0
- package/js/rte/src/command/mark.js +583 -0
- package/js/rte/src/command/pending-marks.js +216 -0
- package/js/rte/src/command/table.js +207 -0
- package/js/rte/src/command/unstyle.js +248 -0
- package/js/rte/src/config/README.md +96 -0
- package/js/rte/src/config/config.js +127 -0
- package/js/rte/src/core/README.md +41 -0
- package/js/rte/src/core/core.js +156 -0
- package/js/rte/src/history/README.md +83 -0
- package/js/rte/src/history/history.js +283 -0
- package/js/rte/src/input/README.md +155 -0
- package/js/rte/src/input/external-input.js +130 -0
- package/js/rte/src/input/input-pipeline.js +414 -0
- package/js/rte/src/mark/README.md +219 -0
- package/js/rte/src/mark/dom-adapter.js +100 -0
- package/js/rte/src/mark/mark.js +133 -0
- package/js/rte/src/mark/standard.js +76 -0
- package/js/rte/src/model/README.md +71 -0
- package/js/rte/src/model/content-model.js +213 -0
- package/js/rte/src/model/html/README.md +51 -0
- package/js/rte/src/model/html/html-model.js +90 -0
- package/js/rte/src/normalize/execute/README.md +33 -0
- package/js/rte/src/normalize/execute/repair-executor.js +129 -0
- package/js/rte/src/normalize/normalizer/README.md +48 -0
- package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
- package/js/rte/src/normalize/repair/README.md +70 -0
- package/js/rte/src/normalize/repair/repair-planner.js +208 -0
- package/js/rte/src/sanitize/README.md +105 -0
- package/js/rte/src/sanitize/native.js +43 -0
- package/js/rte/src/sanitize/policy.js +202 -0
- package/js/rte/src/selection/README.md +39 -0
- package/js/rte/src/selection/map/README.md +53 -0
- package/js/rte/src/selection/map/point-map.js +307 -0
- package/js/rte/src/selection/ownership/README.md +32 -0
- package/js/rte/src/selection/ownership/ownership.js +42 -0
- package/js/rte/src/selection/point/README.md +34 -0
- package/js/rte/src/selection/point/point.js +83 -0
- package/js/rte/src/selection/range/README.md +38 -0
- package/js/rte/src/selection/range/edit-range.js +223 -0
- package/js/rte/src/selection/snapshot.js +64 -0
- package/js/rte/src/source/README.md +64 -0
- package/js/rte/src/source/source.js +193 -0
- package/js/rte/src/surface/README.md +36 -0
- package/js/rte/src/surface/content.js +16 -0
- package/js/rte/src/surface/surface.js +87 -0
- package/js/rte/src/transaction/README.md +35 -0
- package/js/rte/src/transaction/transaction.js +56 -0
- package/js/rte/src/ui/README.md +255 -0
- package/js/rte/src/ui/chrome.js +214 -0
- package/js/rte/src/ui/handles.js +179 -0
- package/js/rte/src/ui/place.js +25 -0
- package/js/rte/src/ui/toolbar.js +283 -0
- package/js/rte/src/unstyle/README.md +103 -0
- package/js/rte/src/unstyle/unstyle.js +140 -0
- package/js/rte/tables.js +7 -0
- package/js/rte/unstyle.js +8 -0
- package/js/rte0/README.md +37 -0
- package/js/rte0/rte.js +7 -0
- package/js/{rte → rte0}/src/behavior.js +0 -1
- package/js/{rte → rte0}/src/events.js +0 -1
- package/js/{rte → rte0}/src/range.js +0 -2
- package/js/{rte → rte0}/src/ui.items.js +0 -2
- package/js/{rte → rte0}/src/ui.js +0 -1
- package/package.json +1 -1
- package/u2/Element.js +2 -2
- package/u2/plan-scoped-refactoring.md +133 -303
- package/u2/projects.json +18 -2
- package/u2/tools/update.repos.json.js +1 -1
- package/el/qrcode/package.json +0 -22
- /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
- /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/main.css +0 -0
- /package/js/{rte → rte0}/old/old.rte.js +0 -0
- /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
- /package/js/{rte → rte0}/src/fixes.js +0 -0
- /package/js/{rte → rte0}/src/rte.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
- /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
- /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
|
+
});
|