@raylang/elements 0.8.0 → 0.9.0
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/components/code/code.js +8 -5
- package/components/command-palette/command-palette.js +16 -1
- package/components/doc-tabs/doc-tabs.js +255 -0
- package/components/icon-button/icon-button.js +4 -0
- package/components/input/input.js +13 -1
- package/components/menu/menu.js +42 -7
- package/components/mode-input/mode-input.js +221 -0
- package/components/sidebar/sidebar.js +140 -5
- package/components/split/split.js +13 -1
- package/components/tabs/tabs.js +44 -6
- package/components/textarea/textarea.js +13 -1
- package/core/complete.js +252 -0
- package/core/scroll.js +85 -1
- package/core/textlayer.js +214 -0
- package/custom-elements.json +1414 -88
- package/index.js +2 -0
- package/llms-full.txt +184 -12
- package/llms.txt +7 -3
- package/package.json +9 -1
- package/ray-elements.d.ts +158 -6
- package/vscode.html-custom-data.json +397 -4
package/core/complete.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Inline suggestions for a text field (ray-input, ray-textarea): while the
|
|
2
|
+
// user types, `host.complete(text, caret)` says which part of the text a
|
|
3
|
+
// suggestion would replace and with what ({ from, to, items }, or null);
|
|
4
|
+
// a listbox opens under that part, in the top layer, and the chosen item
|
|
5
|
+
// replaces it as an edit (undoable, an ordinary input event).
|
|
6
|
+
//
|
|
7
|
+
// The combobox pattern: the control gets aria-autocomplete, aria-expanded,
|
|
8
|
+
// aria-controls and aria-activedescendant; the listbox lives in the same
|
|
9
|
+
// shadow root, so the ids work. ArrowUp/Down move, Enter or Tab accept,
|
|
10
|
+
// Escape closes, Ctrl+Space asks; the keys are taken before the control's
|
|
11
|
+
// own (Enter submitting, Escape closing a dialog) only while the list is
|
|
12
|
+
// open.
|
|
13
|
+
|
|
14
|
+
import { css, uid } from "./core.js";
|
|
15
|
+
import { TextLayer, syncHighlight } from "./textlayer.js";
|
|
16
|
+
|
|
17
|
+
export const completeStyles = css`
|
|
18
|
+
.suggestions {
|
|
19
|
+
position: fixed;
|
|
20
|
+
inset: auto;
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
min-width: 12rem;
|
|
23
|
+
max-width: min(28rem, calc(100vw - 16px));
|
|
24
|
+
max-height: 14rem;
|
|
25
|
+
margin: 0;
|
|
26
|
+
padding: var(--ray-space-1);
|
|
27
|
+
overflow-y: auto;
|
|
28
|
+
font-size: var(--ray-font-size-sm);
|
|
29
|
+
color: var(--ray-popover-foreground);
|
|
30
|
+
background: var(--ray-popover);
|
|
31
|
+
border: 1px solid var(--ray-border);
|
|
32
|
+
border-radius: var(--ray-radius-default);
|
|
33
|
+
box-shadow: var(--ray-shadow-md);
|
|
34
|
+
}
|
|
35
|
+
.suggestion {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: baseline;
|
|
38
|
+
gap: var(--ray-space-3);
|
|
39
|
+
padding: var(--ray-space-1) var(--ray-space-2);
|
|
40
|
+
border-radius: var(--ray-radius-sm);
|
|
41
|
+
cursor: default;
|
|
42
|
+
white-space: nowrap;
|
|
43
|
+
}
|
|
44
|
+
.suggestion[aria-selected="true"] { color: var(--ray-accent-foreground); background: var(--ray-accent); }
|
|
45
|
+
.suggestion .s-label { flex: none; font-family: var(--ray-font-mono); }
|
|
46
|
+
.suggestion .s-detail { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--ray-muted-foreground); }
|
|
47
|
+
.suggestion .s-description { display: block; font-size: var(--ray-font-size-xs); color: var(--ray-muted-foreground); }
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/** The layer of a field, made once (shared with highlight). */
|
|
51
|
+
export function layerOf(host) {
|
|
52
|
+
host._textLayer ??= new TextLayer(host.control);
|
|
53
|
+
return host._textLayer;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class Completer {
|
|
57
|
+
constructor(host) {
|
|
58
|
+
this.host = host;
|
|
59
|
+
const c = (this.control = host.control);
|
|
60
|
+
this.list = document.createElement("div");
|
|
61
|
+
this.list.className = "suggestions";
|
|
62
|
+
this.list.id = uid("suggestions");
|
|
63
|
+
this.list.setAttribute("part", "suggestions");
|
|
64
|
+
this.list.setAttribute("role", "listbox");
|
|
65
|
+
this.list.popover = "manual";
|
|
66
|
+
host.shadowRoot.append(this.list);
|
|
67
|
+
this.open = null; // { items, index, from, to }
|
|
68
|
+
this.asked = 0;
|
|
69
|
+
this.inserting = false;
|
|
70
|
+
c.setAttribute("aria-autocomplete", "list");
|
|
71
|
+
c.setAttribute("aria-expanded", "false");
|
|
72
|
+
c.setAttribute("aria-controls", this.list.id);
|
|
73
|
+
if (c.localName === "input") c.setAttribute("role", "combobox");
|
|
74
|
+
// Choosing with the pointer keeps the focus (and the caret) in the field.
|
|
75
|
+
this.list.addEventListener("pointerdown", (e) => e.preventDefault());
|
|
76
|
+
this.list.addEventListener("click", (e) => {
|
|
77
|
+
const option = e.target.closest(".suggestion");
|
|
78
|
+
if (option) this.accept(Number(option.dataset.index));
|
|
79
|
+
});
|
|
80
|
+
c.addEventListener("input", () => {
|
|
81
|
+
if (!this.inserting) this.ask();
|
|
82
|
+
});
|
|
83
|
+
c.addEventListener("click", () => this.open && this.ask());
|
|
84
|
+
c.addEventListener("blur", () => this.close());
|
|
85
|
+
// The caret moved along the text (on keyup it has landed): ask where it is now.
|
|
86
|
+
c.addEventListener("keyup", (e) => {
|
|
87
|
+
if (this.open && ["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) this.ask();
|
|
88
|
+
});
|
|
89
|
+
// Captured on the host: before the control's Enter and a dialog's Escape.
|
|
90
|
+
host.addEventListener("keydown", (e) => this.key(e), { capture: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Calls complete() for the text and caret now; shows or closes the list. */
|
|
94
|
+
async ask() {
|
|
95
|
+
const c = this.control;
|
|
96
|
+
const fn = this.host.complete;
|
|
97
|
+
if (typeof fn !== "function" || c.selectionStart !== c.selectionEnd || c.readOnly || c.disabled) {
|
|
98
|
+
this.close();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const n = ++this.asked;
|
|
102
|
+
let result = null;
|
|
103
|
+
try {
|
|
104
|
+
result = await fn(c.value, c.selectionStart);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error("ray-elements: complete() threw", err);
|
|
107
|
+
}
|
|
108
|
+
// A later keystroke asked again, or the field lost the focus meanwhile.
|
|
109
|
+
if (n !== this.asked || this.host.shadowRoot.activeElement !== c) return;
|
|
110
|
+
const items = Array.isArray(result?.items) ? result.items.filter((i) => i && i.value !== undefined) : [];
|
|
111
|
+
if (!items.length) {
|
|
112
|
+
this.close();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const length = c.value.length;
|
|
116
|
+
const from = Math.max(0, Math.min(length, Math.trunc(result.from ?? c.selectionStart)));
|
|
117
|
+
const to = Math.max(from, Math.min(length, Math.trunc(result.to ?? c.selectionStart)));
|
|
118
|
+
this.show(items, from, to);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
show(items, from, to) {
|
|
122
|
+
this.list.setAttribute("aria-label", this.host.suggestionsLabel ?? "Suggestions");
|
|
123
|
+
this.list.replaceChildren(...items.map((item, i) => {
|
|
124
|
+
const option = document.createElement("div");
|
|
125
|
+
option.className = "suggestion";
|
|
126
|
+
option.id = `${this.list.id}-${i}`;
|
|
127
|
+
option.dataset.index = String(i);
|
|
128
|
+
option.setAttribute("role", "option");
|
|
129
|
+
option.setAttribute("part", "suggestion");
|
|
130
|
+
const label = document.createElement("span");
|
|
131
|
+
label.className = "s-label";
|
|
132
|
+
label.textContent = item.label ?? String(item.value);
|
|
133
|
+
option.append(label);
|
|
134
|
+
if (item.detail) {
|
|
135
|
+
const detail = document.createElement("span");
|
|
136
|
+
detail.className = "s-detail";
|
|
137
|
+
detail.textContent = item.detail;
|
|
138
|
+
option.append(detail);
|
|
139
|
+
}
|
|
140
|
+
if (item.description) {
|
|
141
|
+
const d = document.createElement("span");
|
|
142
|
+
d.className = "s-description";
|
|
143
|
+
d.textContent = item.description;
|
|
144
|
+
label.append(d);
|
|
145
|
+
}
|
|
146
|
+
return option;
|
|
147
|
+
}));
|
|
148
|
+
const keep = this.open ? this.open.items[this.open.index]?.value : undefined;
|
|
149
|
+
const index = Math.max(0, items.findIndex((i) => i.value === keep));
|
|
150
|
+
this.open = { items, index, from, to };
|
|
151
|
+
if (!this.list.matches(":popover-open")) this.list.showPopover();
|
|
152
|
+
this.control.setAttribute("aria-expanded", "true");
|
|
153
|
+
this.select(index);
|
|
154
|
+
this.place();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Under the text it would replace; above it when there is no room below.
|
|
158
|
+
place() {
|
|
159
|
+
const at = layerOf(this.host);
|
|
160
|
+
// The layer's text as it is now (it is otherwise repainted on update).
|
|
161
|
+
syncHighlight(this.host);
|
|
162
|
+
const r = at.rectAt(this.open.from);
|
|
163
|
+
// A one-line field: below its box, not inside it; a textarea: below the line.
|
|
164
|
+
const box = at.multi ? r : this.control.parentElement.getBoundingClientRect();
|
|
165
|
+
const w = this.list.offsetWidth;
|
|
166
|
+
const h = this.list.offsetHeight;
|
|
167
|
+
const gap = 4;
|
|
168
|
+
const left = Math.max(8, Math.min(r.left, innerWidth - w - 8));
|
|
169
|
+
const below = box.bottom + gap;
|
|
170
|
+
const top = below + h <= innerHeight - 8 ? below : Math.max(8, box.top - gap - h);
|
|
171
|
+
this.list.style.left = `${left}px`;
|
|
172
|
+
this.list.style.top = `${top}px`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
select(index) {
|
|
176
|
+
this.open.index = index;
|
|
177
|
+
const options = [...this.list.children];
|
|
178
|
+
options.forEach((o, i) => o.setAttribute("aria-selected", String(i === index)));
|
|
179
|
+
const current = options[index];
|
|
180
|
+
if (!current) return;
|
|
181
|
+
this.control.setAttribute("aria-activedescendant", current.id);
|
|
182
|
+
const top = current.offsetTop;
|
|
183
|
+
if (top < this.list.scrollTop) this.list.scrollTop = top;
|
|
184
|
+
else if (top + current.offsetHeight > this.list.scrollTop + this.list.clientHeight) this.list.scrollTop = top + current.offsetHeight - this.list.clientHeight;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
close() {
|
|
188
|
+
this.asked++;
|
|
189
|
+
if (!this.open) return;
|
|
190
|
+
this.open = null;
|
|
191
|
+
this.control.setAttribute("aria-expanded", "false");
|
|
192
|
+
this.control.removeAttribute("aria-activedescendant");
|
|
193
|
+
if (this.list.matches(":popover-open")) this.list.hidePopover();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Replaces from–to with the item's value, as an edit. */
|
|
197
|
+
accept(index) {
|
|
198
|
+
const { items, from, to } = this.open ?? {};
|
|
199
|
+
const item = items?.[index];
|
|
200
|
+
this.close();
|
|
201
|
+
if (!item) return;
|
|
202
|
+
const c = this.control;
|
|
203
|
+
const text = String(item.value);
|
|
204
|
+
c.focus();
|
|
205
|
+
c.setSelectionRange(from, to);
|
|
206
|
+
this.inserting = true;
|
|
207
|
+
try {
|
|
208
|
+
// execCommand edits the focused element and keeps undo; without the
|
|
209
|
+
// focus (a window in the background) it may do nothing: set it then.
|
|
210
|
+
const focused = this.host.shadowRoot.activeElement === c;
|
|
211
|
+
if (!focused || !document.execCommand("insertText", false, text) || c.value.slice(from, from + text.length) !== text) {
|
|
212
|
+
c.value = c.value.slice(0, from) + text + c.value.slice(to);
|
|
213
|
+
c.setSelectionRange(from + text.length, from + text.length);
|
|
214
|
+
c.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
this.inserting = false;
|
|
218
|
+
}
|
|
219
|
+
this.host.emit("ray-complete", { item, from, to });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
key(e) {
|
|
223
|
+
if (e.composedPath()[0] !== this.control) return;
|
|
224
|
+
if (e.key === " " && e.ctrlKey) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
this.ask();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!this.open) return;
|
|
230
|
+
const n = this.open.items.length;
|
|
231
|
+
const take = () => {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
e.stopPropagation();
|
|
234
|
+
};
|
|
235
|
+
switch (e.key) {
|
|
236
|
+
case "ArrowDown": take(); this.select((this.open.index + 1) % n); break;
|
|
237
|
+
case "ArrowUp": take(); this.select((this.open.index - 1 + n) % n); break;
|
|
238
|
+
case "Enter":
|
|
239
|
+
case "Tab": take(); this.accept(this.open.index); break;
|
|
240
|
+
case "Escape": take(); this.close(); break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Sets up inline suggestions on `host` (ray-input, ray-textarea) the first
|
|
247
|
+
* time it has a `complete` function; closes the list when it is removed.
|
|
248
|
+
*/
|
|
249
|
+
export function syncComplete(host) {
|
|
250
|
+
if (typeof host.complete === "function") host._completer ??= new Completer(host);
|
|
251
|
+
else host._completer?.close();
|
|
252
|
+
}
|
package/core/scroll.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Which element scrolls the page an element sits on, and the direction of
|
|
2
2
|
// its scrolling: for the bars that hide while scrolling down and come back
|
|
3
|
-
// on the way up (ray-app-bar, ray-bottom-nav).
|
|
3
|
+
// on the way up (ray-app-bar, ray-bottom-nav); and rows that scroll sideways.
|
|
4
|
+
|
|
5
|
+
import { css } from "./core.js";
|
|
6
|
+
import { isRtl } from "./direction.js";
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* The scroller of `el`: `target` (an element or a selector) when given,
|
|
@@ -56,3 +59,84 @@ export function watchScroll(scroller, cb, { tolerance = 8 } = {}) {
|
|
|
56
59
|
cancelAnimationFrame(frame);
|
|
57
60
|
};
|
|
58
61
|
}
|
|
62
|
+
|
|
63
|
+
// --- a row that scrolls sideways (tab bars) -----------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The look of a SideScroller's row (class "list"): the edge that hides items
|
|
67
|
+
* fades out, following the writing direction.
|
|
68
|
+
*/
|
|
69
|
+
export const sideScrollerStyles = css`
|
|
70
|
+
.list { --_fade-start: 0px; --_fade-end: 0px; --_fade-dir: to right; }
|
|
71
|
+
.list:dir(rtl) { --_fade-dir: to left; }
|
|
72
|
+
:host([data-overflow-start]) .list { --_fade-start: 2.5rem; }
|
|
73
|
+
:host([data-overflow-end]) .list { --_fade-end: 2.5rem; }
|
|
74
|
+
:host([data-overflow-start]) .list, :host([data-overflow-end]) .list {
|
|
75
|
+
-webkit-mask-image: linear-gradient(var(--_fade-dir), transparent, #000 var(--_fade-start), #000 calc(100% - var(--_fade-end)), transparent);
|
|
76
|
+
mask-image: linear-gradient(var(--_fade-dir), transparent, #000 var(--_fade-start), #000 calc(100% - var(--_fade-end)), transparent);
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A row of items that scrolls sideways (ray-tabs, ray-doc-tabs): it marks
|
|
82
|
+
* `host` with data-overflow-start / data-overflow-end (following the writing
|
|
83
|
+
* direction), turns a vertical wheel into a sideways scroll while the row
|
|
84
|
+
* can move (at its ends the page scrolls), and scrolls an item into view,
|
|
85
|
+
* moving the row only. `active()` false (a vertical tab list) turns it off;
|
|
86
|
+
* `onResize` runs when the row's size changes.
|
|
87
|
+
*/
|
|
88
|
+
export class SideScroller {
|
|
89
|
+
constructor(host, row, { active = () => true, onResize = () => {} } = {}) {
|
|
90
|
+
this.host = host;
|
|
91
|
+
this.row = row;
|
|
92
|
+
this.active = active;
|
|
93
|
+
row.addEventListener("scroll", () => this.update(), { passive: true });
|
|
94
|
+
row.addEventListener("wheel", (e) => this.#wheel(e), { passive: false });
|
|
95
|
+
this.ro = typeof ResizeObserver === "function" ? new ResizeObserver(() => onResize()) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
connect() {
|
|
99
|
+
this.ro?.observe(this.row);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
disconnect() {
|
|
103
|
+
this.ro?.disconnect();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How far the row is scrolled from its start, and how much is left to its end. */
|
|
107
|
+
scrolled() {
|
|
108
|
+
// Right to left, scrollLeft runs from 0 to negative.
|
|
109
|
+
const start = Math.abs(this.row.scrollLeft);
|
|
110
|
+
return { start, end: this.row.scrollWidth - this.row.clientWidth - start };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Sets the host's overflow marks. */
|
|
114
|
+
update() {
|
|
115
|
+
const on = this.active();
|
|
116
|
+
const { start, end } = this.scrolled();
|
|
117
|
+
this.host.toggleAttribute("data-overflow-start", on && start > 1);
|
|
118
|
+
this.host.toggleAttribute("data-overflow-end", on && end > 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Scrolls the row to show `el`; false before layout (try again later). */
|
|
122
|
+
reveal(el) {
|
|
123
|
+
if (!el || !this.active()) return true;
|
|
124
|
+
const r = this.row.getBoundingClientRect();
|
|
125
|
+
const e = el.getBoundingClientRect();
|
|
126
|
+
if (!r.width) return false;
|
|
127
|
+
if (e.left < r.left) this.row.scrollLeft -= r.left - e.left;
|
|
128
|
+
else if (e.right > r.right) this.row.scrollLeft += e.right - r.right;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#wheel(e) {
|
|
133
|
+
if (!this.active() || e.ctrlKey || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
134
|
+
const forward = e.deltaY > 0;
|
|
135
|
+
const { start, end } = this.scrolled();
|
|
136
|
+
if (forward ? end <= 1 : start <= 1) return;
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
const step = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
|
|
139
|
+
this.row.scrollLeft += isRtl(this.host) ? -step : step;
|
|
140
|
+
this.update();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// A layer behind a text control (the <input> of ray-input, the <textarea>
|
|
2
|
+
// of ray-textarea) with the same text laid out the same way, transparent,
|
|
3
|
+
// to paint ranges of it: `highlight` tints tokens such as {{variables}},
|
|
4
|
+
// and the position of any character can be measured (to place suggestions
|
|
5
|
+
// under the caret). The control keeps its own text, caret, selection and
|
|
6
|
+
// IME; the layer only paints behind it.
|
|
7
|
+
|
|
8
|
+
import { css } from "./core.js";
|
|
9
|
+
|
|
10
|
+
/** The layer's look, for the component's styles. */
|
|
11
|
+
export const textLayerStyles = css`
|
|
12
|
+
.control { position: relative; }
|
|
13
|
+
.text-layer {
|
|
14
|
+
position: absolute;
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
color: transparent;
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
}
|
|
22
|
+
.text-layer > div { flex: none; white-space: pre; }
|
|
23
|
+
.text-layer.multi { display: block; }
|
|
24
|
+
.text-layer.multi > div { white-space: pre-wrap; overflow-wrap: break-word; }
|
|
25
|
+
.text-layer mark {
|
|
26
|
+
--_tint: color-mix(in srgb, var(--ray-primary) 18%, transparent);
|
|
27
|
+
color: transparent;
|
|
28
|
+
background: var(--_tint);
|
|
29
|
+
border-radius: 3px;
|
|
30
|
+
box-shadow: 0 0 0 1.5px var(--_tint);
|
|
31
|
+
}
|
|
32
|
+
.text-layer mark[data-kind="error"] {
|
|
33
|
+
--_tint: color-mix(in srgb, var(--ray-destructive) 16%, transparent);
|
|
34
|
+
text-decoration: underline wavy var(--ray-destructive);
|
|
35
|
+
text-decoration-skip-ink: none;
|
|
36
|
+
text-underline-offset: 3px;
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
// Copied from the control so the layer's text falls on the same pixels.
|
|
41
|
+
const LAYOUT = [
|
|
42
|
+
"fontFamily", "fontSize", "fontWeight", "fontStyle", "fontStretch", "fontFeatureSettings", "fontVariantLigatures",
|
|
43
|
+
"letterSpacing", "wordSpacing", "lineHeight", "textTransform", "textIndent", "tabSize", "direction",
|
|
44
|
+
"paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The ranges a highlight function gives, checked and sorted: within the
|
|
49
|
+
* text, not overlapping (an overlapping one is dropped), start < end.
|
|
50
|
+
* @param {Array<{start: number, end: number, kind?: string, title?: string}> | null | undefined} ranges
|
|
51
|
+
* @param {number} length
|
|
52
|
+
*/
|
|
53
|
+
export function cleanRanges(ranges, length) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const sorted = (Array.isArray(ranges) ? ranges : [])
|
|
56
|
+
.filter((r) => r && Number.isFinite(r.start) && Number.isFinite(r.end))
|
|
57
|
+
.map((r) => ({ ...r, start: Math.max(0, Math.trunc(r.start)), end: Math.min(length, Math.trunc(r.end)) }))
|
|
58
|
+
.filter((r) => r.start < r.end)
|
|
59
|
+
.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
60
|
+
let at = 0;
|
|
61
|
+
for (const r of sorted) {
|
|
62
|
+
if (r.start < at) continue;
|
|
63
|
+
out.push(r);
|
|
64
|
+
at = r.end;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class TextLayer {
|
|
70
|
+
/** @param {HTMLInputElement | HTMLTextAreaElement} control */
|
|
71
|
+
constructor(control) {
|
|
72
|
+
this.control = control;
|
|
73
|
+
this.multi = control.localName === "textarea";
|
|
74
|
+
this.el = document.createElement("div");
|
|
75
|
+
this.el.className = "text-layer" + (this.multi ? " multi" : "");
|
|
76
|
+
this.el.setAttribute("part", "text-layer");
|
|
77
|
+
this.el.setAttribute("aria-hidden", "true");
|
|
78
|
+
this.text = document.createElement("div");
|
|
79
|
+
this.el.append(this.text);
|
|
80
|
+
control.before(this.el);
|
|
81
|
+
this.ranges = [];
|
|
82
|
+
this._shown = null;
|
|
83
|
+
control.addEventListener("scroll", () => this.scroll(), { passive: true });
|
|
84
|
+
// A one-line input scrolls with the caret without a scroll event: follow
|
|
85
|
+
// it while focused.
|
|
86
|
+
let following = 0;
|
|
87
|
+
const follow = () => {
|
|
88
|
+
this.scroll();
|
|
89
|
+
following = requestAnimationFrame(follow);
|
|
90
|
+
};
|
|
91
|
+
control.addEventListener("focus", () => {
|
|
92
|
+
if (!this.multi && !following) following = requestAnimationFrame(follow);
|
|
93
|
+
});
|
|
94
|
+
control.addEventListener("blur", () => {
|
|
95
|
+
cancelAnimationFrame(following);
|
|
96
|
+
following = 0;
|
|
97
|
+
this.scroll();
|
|
98
|
+
});
|
|
99
|
+
// Placed on the next frame: moving the layer inside the observer's
|
|
100
|
+
// callback would loop it.
|
|
101
|
+
if (typeof ResizeObserver === "function") new ResizeObserver(() => requestAnimationFrame(() => this.place())).observe(control);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Lays out the control's text with `ranges` marked. */
|
|
105
|
+
paint(ranges) {
|
|
106
|
+
const value = this.control.value;
|
|
107
|
+
this.ranges = cleanRanges(ranges, value.length);
|
|
108
|
+
const key = value + "\u0000" + JSON.stringify(this.ranges.map((r) => [r.start, r.end, r.kind ?? ""]));
|
|
109
|
+
if (this._shown !== key) {
|
|
110
|
+
this._shown = key;
|
|
111
|
+
const nodes = [];
|
|
112
|
+
let at = 0;
|
|
113
|
+
for (const r of this.ranges) {
|
|
114
|
+
if (r.start > at) nodes.push(value.slice(at, r.start));
|
|
115
|
+
const mark = document.createElement("mark");
|
|
116
|
+
mark.textContent = value.slice(r.start, r.end);
|
|
117
|
+
const kind = String(r.kind ?? "").replace(/[^a-z0-9-]/gi, "");
|
|
118
|
+
mark.setAttribute("part", kind ? `highlight highlight-${kind}` : "highlight");
|
|
119
|
+
if (kind) mark.dataset.kind = kind;
|
|
120
|
+
nodes.push(mark);
|
|
121
|
+
at = r.end;
|
|
122
|
+
}
|
|
123
|
+
if (at < value.length) nodes.push(value.slice(at));
|
|
124
|
+
// A trailing newline takes a line in a textarea but not in a <div>.
|
|
125
|
+
if (this.multi && value.endsWith("\n")) nodes.push(" ");
|
|
126
|
+
this.text.replaceChildren(...nodes);
|
|
127
|
+
}
|
|
128
|
+
this.place();
|
|
129
|
+
this.scroll();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
place() {
|
|
133
|
+
const c = this.control;
|
|
134
|
+
const cs = getComputedStyle(c);
|
|
135
|
+
for (const k of LAYOUT) this.el.style[k] = cs[k];
|
|
136
|
+
// offsetLeft is measured from the left, whatever the direction.
|
|
137
|
+
this.el.style.left = `${c.offsetLeft}px`;
|
|
138
|
+
this.el.style.top = `${c.offsetTop}px`;
|
|
139
|
+
// clientWidth leaves out a scrollbar: the text wraps at the same width.
|
|
140
|
+
this.el.style.width = `${this.multi ? c.clientWidth : c.offsetWidth}px`;
|
|
141
|
+
this.el.style.height = `${this.multi ? c.clientHeight : c.offsetHeight}px`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
scroll() {
|
|
145
|
+
const c = this.control;
|
|
146
|
+
this.text.style.transform = this.multi ? `translateY(${-c.scrollTop}px)` : `translateX(${-c.scrollLeft}px)`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The viewport rectangle of the character at `offset` (the end of the text: its last one's end). */
|
|
150
|
+
rectAt(offset) {
|
|
151
|
+
const walker = document.createTreeWalker(this.text, NodeFilter.SHOW_TEXT);
|
|
152
|
+
let left = Math.max(0, offset);
|
|
153
|
+
let last = null;
|
|
154
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
155
|
+
last = n;
|
|
156
|
+
if (left < n.length) {
|
|
157
|
+
const range = document.createRange();
|
|
158
|
+
range.setStart(n, left);
|
|
159
|
+
range.setEnd(n, left + 1);
|
|
160
|
+
const r = range.getBoundingClientRect();
|
|
161
|
+
if (r.width || r.height) return r;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
left -= n.length;
|
|
165
|
+
}
|
|
166
|
+
if (last && last.length) {
|
|
167
|
+
const range = document.createRange();
|
|
168
|
+
range.setStart(last, last.length - 1);
|
|
169
|
+
range.setEnd(last, last.length);
|
|
170
|
+
const r = range.getBoundingClientRect();
|
|
171
|
+
if (r.width || r.height) return new DOMRect(r.right, r.top, 0, r.height);
|
|
172
|
+
}
|
|
173
|
+
const c = this.control.getBoundingClientRect();
|
|
174
|
+
return new DOMRect(c.left, c.top, 0, c.height);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The range under a point of the viewport, or null. */
|
|
178
|
+
rangeAt(x, y) {
|
|
179
|
+
const marks = [...this.text.querySelectorAll("mark")];
|
|
180
|
+
for (let i = 0; i < marks.length; i++) {
|
|
181
|
+
for (const r of marks[i].getClientRects()) {
|
|
182
|
+
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return this.ranges[i];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Paints `host.highlight(text)` behind `host.control` (ray-input,
|
|
191
|
+
* ray-textarea): the layer is made the first time a highlight function is
|
|
192
|
+
* set. A range's `title` becomes the field's tooltip under the pointer.
|
|
193
|
+
*/
|
|
194
|
+
export function syncHighlight(host) {
|
|
195
|
+
const fn = typeof host.highlight === "function" ? host.highlight : null;
|
|
196
|
+
if (!fn && !host._textLayer) return;
|
|
197
|
+
host._textLayer ??= new TextLayer(host.control);
|
|
198
|
+
if (!host._highlightTitles) {
|
|
199
|
+
host._highlightTitles = true;
|
|
200
|
+
host.control.addEventListener("pointermove", (e) => {
|
|
201
|
+
const title = host._textLayer.rangeAt(e.clientX, e.clientY)?.title ?? "";
|
|
202
|
+
if (host.control.title !== title) host.control.title = title;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
let ranges = [];
|
|
206
|
+
if (fn) {
|
|
207
|
+
try {
|
|
208
|
+
ranges = fn(host.control.value);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error("ray-elements: highlight() threw", err);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
host._textLayer.paint(ranges);
|
|
214
|
+
}
|