rich-input 1.0.0 → 1.1.1
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/README.md +24 -18
- package/assets/rich-input-parts.svg +66 -65
- package/components/rich-input.js +139 -7
- package/index.js +5 -1
- package/package.json +1 -1
- package/utils/contenteditable-adapter.js +421 -0
- package/utils/highlights.js +50 -16
- package/utils/positioning.js +122 -1
package/README.md
CHANGED
|
@@ -15,14 +15,14 @@ The `<rich-input>` component is a rich input field that acts like a standard `<i
|
|
|
15
15
|
- **Dual Contextual Autocomplete**:
|
|
16
16
|
- **Keywords**: Typing at the start of a token (e.g. typing `a`) suggests configured keywords like `artist:` and `style:`.
|
|
17
17
|
- **Values**: Typing within a keyword value (e.g. `label:"K` or `label:K`) suggests matching options like `"Keinemusik"` and `"Kranky"`.
|
|
18
|
-
- **Range-Based Positioning via OpaqueRange**: Positions autocomplete dropdown popovers anchored to the start of the active `OpaqueRange` (e.g. at the opening quotation mark of a value) using `range.getBoundingClientRect()`, rather than shifting with the cursor.
|
|
18
|
+
- **Range-Based Positioning via OpaqueRange**: Positions autocomplete dropdown popovers anchored to the start of the active `OpaqueRange` (e.g. at the opening quotation mark of a value) using `range.getBoundingClientRect()`, rather than shifting with the cursor (with a hidden mirror-div fallback in unsupported browsers).
|
|
19
19
|
- **Native In-Input Highlighting via CSS Custom Highlight API**: Highlights keyword values inside the `<input>` control using standard CSS rules like `::highlight(label)` or `::highlight(year)` without brittle mirror-div overlays.
|
|
20
20
|
- **Declarative Configuration via `<datalist>`**: Configure keywords and options purely in HTML by nesting standard `<datalist>` elements with `<option>` tags inside `<rich-input>`.
|
|
21
21
|
- **Rich Option Markup**: Embed custom HTML markup (such as logos, images, icons, and avatars) directly inside `<option>` elements for rich, visual suggestion popovers.
|
|
22
22
|
- **Form Associated**: Implements `static formAssociated = true` and `ElementInternals` to participate seamlessly in `<form>` submission, `FormData`, and form reset lifecycles.
|
|
23
23
|
- **Shadow Parts Theming (`::part`)**: Full CSS customizability using `::part(input)`, `::part(control)`, `::part(popover)`, `::part(suggestion-item)`, etc.
|
|
24
24
|
- **Accessible (W3C Combobox Pattern)**: ARIA 1.2 compliant combobox with keyboard navigation (`ArrowUp`, `ArrowDown`, `Enter`, `Tab`, `Escape`), `aria-expanded`, and `aria-activedescendant`.
|
|
25
|
-
- **Graceful Fallback**: Automatically feature-detects `
|
|
25
|
+
- **Graceful Fallback**: Automatically feature-detects browser capabilities. In browsers with no `OpaqueRange` support but with CSS Custom Highlight API support (e.g. Safari 17.2+, Firefox 141+), it falls back to an adapted `[contenteditable]` element to provide in-input syntax highlighting via standard DOM `Range`s. In environments without `OpaqueRange`, dropdown popovers are positioned using a hidden mirror-div measurement fallback.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -40,7 +40,7 @@ The visual below illustrates the internal Shadow DOM elements, exposed CSS Shado
|
|
|
40
40
|
- `::part(input)`: The native `<input type="text">` where users type.
|
|
41
41
|
- `::highlight(<keyword>)`: Target pseudo-element for styling keyword values via the CSS Custom Highlight API (e.g. `::highlight(label)`, `::highlight(year)`).
|
|
42
42
|
- `::part(clear-button)`: The clear button (visible when text is present).
|
|
43
|
-
- `::part(popover)`: The autocomplete dropdown popover container anchored to the start of the active range via `OpaqueRange
|
|
43
|
+
- `::part(popover)`: The autocomplete dropdown popover container anchored to the start of the active range via `OpaqueRange` (or mirror-div fallback).
|
|
44
44
|
- `::part(suggestions-header)`: The header bar at the top of the suggestions popover.
|
|
45
45
|
- `::part(suggestions-list)`: The `<ul>` container holding autocomplete suggestion items.
|
|
46
46
|
- `::part(suggestion-item)`: Each suggestion `<li>` row.
|
|
@@ -233,16 +233,18 @@ const highlight = new Highlight(valueRange);
|
|
|
233
233
|
CSS.highlights.set('label', highlight);
|
|
234
234
|
```
|
|
235
235
|
|
|
236
|
-
`<rich-input>` automatically checks `typeof HTMLInputElement.prototype.createValueRange === 'function'`. On supported browsers, caret tracking and `::highlight()` are applied natively
|
|
236
|
+
`<rich-input>` automatically checks `typeof HTMLInputElement.prototype.createValueRange === 'function'`. On supported browsers (Chromium 152+), caret tracking and `::highlight()` are applied natively via `OpaqueRange`.
|
|
237
|
+
|
|
238
|
+
In browsers without `OpaqueRange` that support the CSS Custom Highlight API (such as Safari 17.2+ and Firefox 141+), `<rich-input>` automatically falls back to an adapted single-line `[contenteditable]` element inside its shadow DOM. This exposes standard DOM `Text` nodes so standard `Range` objects can be created and passed to `CSS.highlights`, delivering on-the-fly `::highlight()` syntax highlighting across browsers. In environments lacking range positioning, popover dropdowns are positioned using a hidden mirror `<div>` text measurement fallback.
|
|
237
239
|
|
|
238
240
|
---
|
|
239
241
|
|
|
240
242
|
## Styling Highlights with the CSS Custom Highlight API
|
|
241
243
|
|
|
242
|
-
Values corresponding to configured keywords are registered into the global `CSS.highlights` registry
|
|
244
|
+
Values corresponding to configured keywords are registered into the global `CSS.highlights` registry and styled using standard CSS `::highlight(keyword)` pseudo-elements in your stylesheet:
|
|
243
245
|
|
|
244
246
|
```css
|
|
245
|
-
/* Style the value set in label:"
|
|
247
|
+
/* Style the value set in label:"XL Recordings" */
|
|
246
248
|
::highlight(label) {
|
|
247
249
|
background-color: oklch(0.92 0.08 240);
|
|
248
250
|
color: oklch(0.28 0.14 240);
|
|
@@ -261,18 +263,6 @@ Values corresponding to configured keywords are registered into the global `CSS.
|
|
|
261
263
|
color: oklch(0.32 0.14 320);
|
|
262
264
|
}
|
|
263
265
|
|
|
264
|
-
/* Style musical style filters */
|
|
265
|
-
::highlight(style) {
|
|
266
|
-
background-color: oklch(0.93 0.08 190);
|
|
267
|
-
color: oklch(0.3 0.12 190);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/* Style genre filters */
|
|
271
|
-
::highlight(genre) {
|
|
272
|
-
background-color: oklch(0.93 0.1 145);
|
|
273
|
-
color: oklch(0.3 0.14 145);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
266
|
/* Generic prefix highlight for keyword labels (e.g. "label:", "year:") */
|
|
277
267
|
::highlight(rich-input-keyword) {
|
|
278
268
|
color: #64748b;
|
|
@@ -280,6 +270,22 @@ Values corresponding to configured keywords are registered into the global `CSS.
|
|
|
280
270
|
}
|
|
281
271
|
```
|
|
282
272
|
|
|
273
|
+
In browsers using the `[contenteditable]` fallback inside Shadow DOM (such as Safari and Firefox), `<rich-input>` automatically syncs document-level `::highlight()` rules into its shadow stylesheet so that highlighting works across shadow boundaries without extra markup.
|
|
274
|
+
|
|
275
|
+
For self-contained widgets or instance-specific style overrides, `<rich-input>` also supports an optional embedded `<style>` block as a direct child, which is automatically injected into the shadow root:
|
|
276
|
+
|
|
277
|
+
```html
|
|
278
|
+
<rich-input value='artist:"Aphex Twin" label:"XL Recordings"'>
|
|
279
|
+
<style>
|
|
280
|
+
::highlight(label) {
|
|
281
|
+
background-color: #dbeafe;
|
|
282
|
+
color: #1e40af;
|
|
283
|
+
}
|
|
284
|
+
</style>
|
|
285
|
+
<datalist id="label" label="Record Label">...</datalist>
|
|
286
|
+
</rich-input>
|
|
287
|
+
```
|
|
288
|
+
|
|
283
289
|
> **Note:** Supported CSS properties on `::highlight()` include `color`, `background-color`, `text-decoration`, `text-shadow`, `-webkit-text-stroke-color`, `-webkit-text-stroke-width`, and `-webkit-text-fill-color`.
|
|
284
290
|
|
|
285
291
|
---
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1340 480" width="100%" height="100%">
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
:
|
|
2
|
+
<style>
|
|
3
|
+
@scope {
|
|
4
|
+
:scope {
|
|
5
5
|
--canvas-bg: #f8fafc;
|
|
6
6
|
--card-bg: #ffffff;
|
|
7
7
|
--border-color: #cbd5e1;
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
@media (prefers-color-scheme: dark) {
|
|
34
|
-
:
|
|
34
|
+
:scope {
|
|
35
35
|
--canvas-bg: #0f172a;
|
|
36
36
|
--card-bg: #1e293b;
|
|
37
37
|
--border-color: #334155;
|
|
@@ -148,116 +148,117 @@
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/* When hovering the SVG, lines and labels go down to opacity 0.5 */
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
:scope:hover .code-label,
|
|
152
|
+
:scope:hover .guide-line,
|
|
153
|
+
:scope:hover .guide-box,
|
|
154
|
+
:scope:hover .guide-dot {
|
|
155
155
|
opacity: 0.4;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/* When a specific code-label is hovered, dim all other labels, lines, boxes, and comp-parts */
|
|
159
|
-
|
|
159
|
+
:scope:has(.code-label:hover) .code-label:not(:hover) {
|
|
160
160
|
opacity: 0.18;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
:scope:has(.code-label:hover) .guide-line,
|
|
164
|
+
:scope:has(.code-label:hover) .guide-box,
|
|
165
|
+
:scope:has(.code-label:hover) .guide-dot {
|
|
166
166
|
opacity: 0.05;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
:scope:has(.code-label:hover) .comp-part {
|
|
170
170
|
opacity: 0.25;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/* Specific label hover: keep matching guide line, box, dot, and element at 100% */
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
174
|
+
:scope:has(.lbl-host:hover) .target-host,
|
|
175
|
+
:scope:has(.lbl-control:hover) .target-control,
|
|
176
|
+
:scope:has(.lbl-icon:hover) .target-icon,
|
|
177
|
+
:scope:has(.lbl-input:hover) .target-input,
|
|
178
|
+
:scope:has(.lbl-hl-val:hover) .target-hl-val,
|
|
179
|
+
:scope:has(.lbl-clear:hover) .target-clear,
|
|
180
|
+
:scope:has(.lbl-popover:hover) .target-popover,
|
|
181
|
+
:scope:has(.lbl-header:hover) .target-header,
|
|
182
|
+
:scope:has(.lbl-list:hover) .target-list,
|
|
183
|
+
:scope:has(.lbl-item:hover) .target-item,
|
|
184
|
+
:scope:has(.lbl-active:hover) .target-active,
|
|
185
|
+
:scope:has(.lbl-image:hover) .target-image,
|
|
186
|
+
:scope:has(.lbl-val:hover) .target-val {
|
|
187
187
|
opacity: 1 !important;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/* When host label is hovered, keep entire component at 100% */
|
|
191
|
-
|
|
191
|
+
:scope:has(.lbl-host:hover) .comp-part {
|
|
192
192
|
opacity: 1 !important;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/* Contained parts: when hovering a container, contained parts in the preview also remain at opacity 1 */
|
|
196
196
|
/* Control contains icon, input (including highlight hou), and clear button */
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
197
|
+
:scope:has(.lbl-control:hover) #component-preview .target-icon,
|
|
198
|
+
:scope:has(.lbl-control:hover) #component-preview .target-input,
|
|
199
|
+
:scope:has(.lbl-control:hover) #component-preview .target-hl-val,
|
|
200
|
+
:scope:has(.lbl-control:hover) #component-preview .target-clear {
|
|
201
201
|
opacity: 1 !important;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/* Input contains highlight label (hou text and background pill) */
|
|
205
|
-
|
|
205
|
+
:scope:has(.lbl-input:hover) #component-preview .target-hl-val {
|
|
206
206
|
opacity: 1 !important;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/* Popover contains header, list, and all suggestion items */
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
:scope:has(.lbl-popover:hover) #component-preview .target-header,
|
|
211
|
+
:scope:has(.lbl-popover:hover) #component-preview .target-item,
|
|
212
|
+
:scope:has(.lbl-popover:hover) #component-preview .target-active {
|
|
213
213
|
opacity: 1 !important;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
/* Suggestions list contains all suggestion items */
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
:scope:has(.lbl-list:hover) #component-preview .target-item,
|
|
218
|
+
:scope:has(.lbl-list:hover) #component-preview .target-active {
|
|
219
219
|
opacity: 1 !important;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
/* Thicken active guide line when hovered */
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
223
|
+
:scope:has(.lbl-host:hover) .target-host.guide-line,
|
|
224
|
+
:scope:has(.lbl-control:hover) .target-control.guide-line,
|
|
225
|
+
:scope:has(.lbl-icon:hover) .target-icon.guide-line,
|
|
226
|
+
:scope:has(.lbl-input:hover) .target-input.guide-line,
|
|
227
|
+
:scope:has(.lbl-hl-val:hover) .target-hl-val.guide-line,
|
|
228
|
+
:scope:has(.lbl-clear:hover) .target-clear.guide-line,
|
|
229
|
+
:scope:has(.lbl-popover:hover) .target-popover.guide-line,
|
|
230
|
+
:scope:has(.lbl-header:hover) .target-header.guide-line,
|
|
231
|
+
:scope:has(.lbl-list:hover) .target-list.guide-line,
|
|
232
|
+
:scope:has(.lbl-item:hover) .target-item.guide-line,
|
|
233
|
+
:scope:has(.lbl-active:hover) .target-active.guide-line,
|
|
234
|
+
:scope:has(.lbl-image:hover) .target-image.guide-line,
|
|
235
|
+
:scope:has(.lbl-val:hover) .target-val.guide-line {
|
|
236
236
|
stroke-width: 2px;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
/* Thicken active guide box when hovered */
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
240
|
+
:scope:has(.lbl-host:hover) .target-host.guide-box,
|
|
241
|
+
:scope:has(.lbl-control:hover) .target-control.guide-box,
|
|
242
|
+
:scope:has(.lbl-icon:hover) .target-icon.guide-box,
|
|
243
|
+
:scope:has(.lbl-input:hover) .target-input.guide-box,
|
|
244
|
+
:scope:has(.lbl-hl-val:hover) .target-hl-val.guide-box,
|
|
245
|
+
:scope:has(.lbl-clear:hover) .target-clear.guide-box,
|
|
246
|
+
:scope:has(.lbl-popover:hover) .target-popover.guide-box,
|
|
247
|
+
:scope:has(.lbl-header:hover) .target-header.guide-box,
|
|
248
|
+
:scope:has(.lbl-list:hover) .target-list.guide-box,
|
|
249
|
+
:scope:has(.lbl-item:hover) .target-item.guide-box,
|
|
250
|
+
:scope:has(.lbl-active:hover) .target-active.guide-box,
|
|
251
|
+
:scope:has(.lbl-image:hover) .target-image.guide-box,
|
|
252
|
+
:scope:has(.lbl-val:hover) .target-val.guide-box {
|
|
253
253
|
stroke-width: 2px;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
.code-label:hover {
|
|
257
257
|
opacity: 1 !important;
|
|
258
258
|
}
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
}
|
|
260
|
+
</style>
|
|
261
|
+
<defs>
|
|
261
262
|
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
|
|
262
263
|
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-opacity="0.12"/>
|
|
263
264
|
</filter>
|
package/components/rich-input.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion } from '../utils/query-parser.js';
|
|
7
7
|
import { highlightManager, isOpaqueRangeSupported, isHighlightSupported } from '../utils/highlights.js';
|
|
8
8
|
import { getCaretCoordinates, positionPopover } from '../utils/positioning.js';
|
|
9
|
+
import { setupContentEditableAdapter, isContentEditableFallbackActive } from '../utils/contenteditable-adapter.js';
|
|
9
10
|
|
|
10
11
|
const TEMPLATE = document.createElement('template');
|
|
11
12
|
TEMPLATE.innerHTML = `
|
|
@@ -89,12 +90,41 @@ TEMPLATE.innerHTML = `
|
|
|
89
90
|
line-height: 1.5;
|
|
90
91
|
padding: 0.625rem 0;
|
|
91
92
|
color: var(--ri-input-color, var(--rs-input-color, #111827));
|
|
93
|
+
white-space: pre;
|
|
94
|
+
overflow-x: auto;
|
|
95
|
+
overflow-y: hidden;
|
|
96
|
+
scrollbar-width: none;
|
|
97
|
+
box-sizing: border-box;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.search-input::-webkit-scrollbar {
|
|
101
|
+
display: none;
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
.search-input::placeholder {
|
|
95
105
|
color: var(--ri-placeholder-color, var(--rs-placeholder-color, #9ca3af));
|
|
96
106
|
}
|
|
97
107
|
|
|
108
|
+
/* Contenteditable empty placeholder fallback */
|
|
109
|
+
.search-input:empty::before {
|
|
110
|
+
content: attr(data-placeholder);
|
|
111
|
+
color: var(--ri-placeholder-color, var(--rs-placeholder-color, #9ca3af));
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
display: inline-block;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Generic prefix highlight for keywords */
|
|
117
|
+
::highlight(rich-input-keyword) {
|
|
118
|
+
color: var(--ri-keyword-color, #64748b);
|
|
119
|
+
text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Visually hide datalists and custom style tags in slot */
|
|
123
|
+
::slotted(datalist),
|
|
124
|
+
::slotted(style) {
|
|
125
|
+
display: none !important;
|
|
126
|
+
}
|
|
127
|
+
|
|
98
128
|
.clear-button {
|
|
99
129
|
flex-shrink: 0;
|
|
100
130
|
display: inline-flex;
|
|
@@ -266,6 +296,45 @@ TEMPLATE.innerHTML = `
|
|
|
266
296
|
</div>
|
|
267
297
|
`;
|
|
268
298
|
|
|
299
|
+
function extractHighlightRules(rules) {
|
|
300
|
+
let css = '';
|
|
301
|
+
for (const rule of rules) {
|
|
302
|
+
try {
|
|
303
|
+
if (rule.cssRules && rule.cssRules.length > 0) {
|
|
304
|
+
css += extractHighlightRules(rule.cssRules);
|
|
305
|
+
} else if (rule.cssText && rule.cssText.includes('::highlight')) {
|
|
306
|
+
css += rule.cssText + '\n';
|
|
307
|
+
}
|
|
308
|
+
} catch (e) {}
|
|
309
|
+
}
|
|
310
|
+
return css;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function syncDocumentHighlightStyles(shadowRoot) {
|
|
314
|
+
if (typeof document === 'undefined' || !shadowRoot || !isContentEditableFallbackActive) return;
|
|
315
|
+
try {
|
|
316
|
+
let highlightCss = '';
|
|
317
|
+
for (const sheet of document.styleSheets) {
|
|
318
|
+
try {
|
|
319
|
+
if (sheet.cssRules) {
|
|
320
|
+
highlightCss += extractHighlightRules(sheet.cssRules);
|
|
321
|
+
}
|
|
322
|
+
} catch (e) {}
|
|
323
|
+
}
|
|
324
|
+
if (highlightCss) {
|
|
325
|
+
let styleEl = shadowRoot.getElementById('ri-synced-highlight-styles');
|
|
326
|
+
if (!styleEl) {
|
|
327
|
+
styleEl = document.createElement('style');
|
|
328
|
+
styleEl.id = 'ri-synced-highlight-styles';
|
|
329
|
+
shadowRoot.appendChild(styleEl);
|
|
330
|
+
}
|
|
331
|
+
if (styleEl.textContent !== highlightCss) {
|
|
332
|
+
styleEl.textContent = highlightCss;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} catch (e) {}
|
|
336
|
+
}
|
|
337
|
+
|
|
269
338
|
export class RichInput extends HTMLElement {
|
|
270
339
|
static formAssociated = true;
|
|
271
340
|
|
|
@@ -289,7 +358,26 @@ export class RichInput extends HTMLElement {
|
|
|
289
358
|
this.attachShadow({ mode: 'open' });
|
|
290
359
|
this.shadowRoot.appendChild(TEMPLATE.content.cloneNode(true));
|
|
291
360
|
|
|
292
|
-
|
|
361
|
+
// In browsers lacking OpaqueRange but supporting CSS Custom Highlights (Safari 17.2+, Firefox 141+),
|
|
362
|
+
// swap in a [contenteditable] element to expose standard DOM Text nodes for Custom Highlights.
|
|
363
|
+
const useContentEditable = isContentEditableFallbackActive;
|
|
364
|
+
if (useContentEditable) {
|
|
365
|
+
const existingInput = this.shadowRoot.querySelector('.search-input');
|
|
366
|
+
const editableDiv = document.createElement('div');
|
|
367
|
+
editableDiv.className = 'search-input';
|
|
368
|
+
editableDiv.setAttribute('part', 'input');
|
|
369
|
+
editableDiv.setAttribute('role', 'combobox');
|
|
370
|
+
editableDiv.setAttribute('aria-autocomplete', 'list');
|
|
371
|
+
editableDiv.setAttribute('aria-expanded', 'false');
|
|
372
|
+
editableDiv.setAttribute('aria-haspopup', 'listbox');
|
|
373
|
+
editableDiv.setAttribute('spellcheck', 'false');
|
|
374
|
+
editableDiv.setAttribute('tabindex', '0');
|
|
375
|
+
existingInput.replaceWith(editableDiv);
|
|
376
|
+
this._input = setupContentEditableAdapter(editableDiv, this);
|
|
377
|
+
} else {
|
|
378
|
+
this._input = this.shadowRoot.querySelector('.search-input');
|
|
379
|
+
}
|
|
380
|
+
|
|
293
381
|
this._popover = this.shadowRoot.querySelector('.popover');
|
|
294
382
|
this._popoverTitle = this.shadowRoot.querySelector('.popover-title');
|
|
295
383
|
this._suggestionsList = this.shadowRoot.querySelector('.suggestions-list');
|
|
@@ -317,15 +405,18 @@ export class RichInput extends HTMLElement {
|
|
|
317
405
|
}
|
|
318
406
|
|
|
319
407
|
connectedCallback() {
|
|
408
|
+
this._syncInjectedStyles();
|
|
409
|
+
syncDocumentHighlightStyles(this.shadowRoot);
|
|
320
410
|
highlightManager.register(this);
|
|
321
411
|
|
|
322
412
|
// Parse initial datalists
|
|
323
413
|
this._loadDatalists();
|
|
324
414
|
|
|
325
|
-
// Listen to changes on light DOM datalists
|
|
415
|
+
// Listen to changes on light DOM datalists and style tags
|
|
326
416
|
this._slot.addEventListener('slotchange', this._onSlotChange);
|
|
327
417
|
this._mutationObserver = new MutationObserver(() => {
|
|
328
418
|
this._loadDatalists();
|
|
419
|
+
this._syncInjectedStyles();
|
|
329
420
|
this.updateHighlights();
|
|
330
421
|
});
|
|
331
422
|
this._mutationObserver.observe(this, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
@@ -587,11 +678,42 @@ export class RichInput extends HTMLElement {
|
|
|
587
678
|
this._configuredKeywords = newMap;
|
|
588
679
|
}
|
|
589
680
|
|
|
681
|
+
// --- Injected <style> Synchronization ---
|
|
682
|
+
_syncInjectedStyles() {
|
|
683
|
+
const styleEls = this.querySelectorAll('style');
|
|
684
|
+
let customCss = '';
|
|
685
|
+
for (const styleEl of styleEls) {
|
|
686
|
+
if (styleEl.textContent) {
|
|
687
|
+
customCss += styleEl.textContent + '\n';
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let targetStyle = this.shadowRoot.getElementById('ri-injected-styles');
|
|
692
|
+
if (customCss.trim()) {
|
|
693
|
+
if (!targetStyle) {
|
|
694
|
+
targetStyle = document.createElement('style');
|
|
695
|
+
targetStyle.id = 'ri-injected-styles';
|
|
696
|
+
this.shadowRoot.appendChild(targetStyle);
|
|
697
|
+
}
|
|
698
|
+
if (targetStyle.textContent !== customCss) {
|
|
699
|
+
targetStyle.textContent = customCss;
|
|
700
|
+
}
|
|
701
|
+
} else if (targetStyle) {
|
|
702
|
+
targetStyle.remove();
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
590
706
|
_onSlotChange() {
|
|
591
707
|
this._loadDatalists();
|
|
708
|
+
this._syncInjectedStyles();
|
|
592
709
|
this.updateHighlights();
|
|
593
710
|
}
|
|
594
711
|
|
|
712
|
+
// --- Public Properties ---
|
|
713
|
+
get inputElement() {
|
|
714
|
+
return this._input;
|
|
715
|
+
}
|
|
716
|
+
|
|
595
717
|
// --- Highlighting with OpaqueRange & Custom Highlight API ---
|
|
596
718
|
_disconnectOwnedRanges() {
|
|
597
719
|
for (const r of this._ownedRanges) {
|
|
@@ -606,10 +728,16 @@ export class RichInput extends HTMLElement {
|
|
|
606
728
|
updateHighlights() {
|
|
607
729
|
this._disconnectOwnedRanges();
|
|
608
730
|
|
|
609
|
-
if (!
|
|
731
|
+
if (!isHighlightSupported) {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (!isOpaqueRangeSupported && typeof this._input?.createValueRange !== 'function') {
|
|
610
735
|
return;
|
|
611
736
|
}
|
|
612
737
|
|
|
738
|
+
this._syncInjectedStyles();
|
|
739
|
+
syncDocumentHighlightStyles(this.shadowRoot);
|
|
740
|
+
|
|
613
741
|
const text = this._input.value;
|
|
614
742
|
if (!text) {
|
|
615
743
|
highlightManager.syncAll();
|
|
@@ -636,7 +764,7 @@ export class RichInput extends HTMLElement {
|
|
|
636
764
|
const start = highlightQuotes ? token.valueStart : token.innerStart;
|
|
637
765
|
const end = highlightQuotes ? token.valueEnd : token.innerEnd;
|
|
638
766
|
|
|
639
|
-
if (end
|
|
767
|
+
if (end > start && end <= text.length) {
|
|
640
768
|
try {
|
|
641
769
|
const valRange = this._input.createValueRange(start, end);
|
|
642
770
|
this._ownedRanges.push(valRange);
|
|
@@ -739,7 +867,10 @@ export class RichInput extends HTMLElement {
|
|
|
739
867
|
}, 150);
|
|
740
868
|
}
|
|
741
869
|
|
|
742
|
-
_onClick() {
|
|
870
|
+
_onClick(e) {
|
|
871
|
+
if (e && typeof e.clientX === 'number' && typeof this._input?.updateCaretFromPoint === 'function') {
|
|
872
|
+
this._input.updateCaretFromPoint(e.clientX, e.clientY);
|
|
873
|
+
}
|
|
743
874
|
this.updateSuggestions('click');
|
|
744
875
|
}
|
|
745
876
|
|
|
@@ -758,7 +889,8 @@ export class RichInput extends HTMLElement {
|
|
|
758
889
|
}
|
|
759
890
|
|
|
760
891
|
_getCurrentOpaqueRange() {
|
|
761
|
-
if (!this._context
|
|
892
|
+
if (!this._context) return null;
|
|
893
|
+
if (!isOpaqueRangeSupported && typeof this._input?.createValueRange !== 'function') return null;
|
|
762
894
|
|
|
763
895
|
const { mode, token, caretPos } = this._context;
|
|
764
896
|
|
|
@@ -851,7 +983,7 @@ export class RichInput extends HTMLElement {
|
|
|
851
983
|
} catch (e) {}
|
|
852
984
|
}
|
|
853
985
|
|
|
854
|
-
// Position popover at the start of the current OpaqueRange
|
|
986
|
+
// Position popover at the start of the current OpaqueRange (or via mirror-div fallback when OpaqueRange is unsupported)
|
|
855
987
|
const currentRange = this._getCurrentOpaqueRange();
|
|
856
988
|
const anchor = currentRange || (context.replaceStart !== undefined ? context.replaceStart : context.caretPos);
|
|
857
989
|
const anchorCoords = getCaretCoordinates(this._input, anchor);
|
package/index.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { RichInput } from './components/rich-input.js';
|
|
7
7
|
import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion } from './utils/query-parser.js';
|
|
8
8
|
import { highlightManager, isOpaqueRangeSupported, isHighlightSupported } from './utils/highlights.js';
|
|
9
|
-
import { getCaretCoordinates, getRangeCoordinates, positionPopover } from './utils/positioning.js';
|
|
9
|
+
import { getCaretCoordinates, getRangeCoordinates, positionPopover, getCaretLeftWithMirrorDiv } from './utils/positioning.js';
|
|
10
|
+
import { setupContentEditableAdapter, isContentEditableFallbackActive } from './utils/contenteditable-adapter.js';
|
|
10
11
|
|
|
11
12
|
if (typeof customElements !== 'undefined') {
|
|
12
13
|
if (!customElements.get('rich-input')) {
|
|
@@ -24,9 +25,12 @@ export {
|
|
|
24
25
|
highlightManager,
|
|
25
26
|
isOpaqueRangeSupported,
|
|
26
27
|
isHighlightSupported,
|
|
28
|
+
isContentEditableFallbackActive,
|
|
29
|
+
setupContentEditableAdapter,
|
|
27
30
|
getCaretCoordinates,
|
|
28
31
|
getRangeCoordinates,
|
|
29
32
|
positionPopover,
|
|
33
|
+
getCaretLeftWithMirrorDiv,
|
|
30
34
|
};
|
|
31
35
|
|
|
32
36
|
export default RichInput;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rich-input",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "A rich input field with keyword-based autocomplete and in-input highlighting powered by <datalist>, OpaqueRange, and the Custom Highlight API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContentEditable adapter for <rich-input>
|
|
3
|
+
*
|
|
4
|
+
* In browsers that do not support the OpaqueRange API (HTMLInputElement.createValueRange)
|
|
5
|
+
* but DO support the CSS Custom Highlight API (Safari 17.2+, Firefox 141+), this adapter
|
|
6
|
+
* enables using a single-line [contenteditable] element inside <rich-input>.
|
|
7
|
+
*
|
|
8
|
+
* This exposes standard DOM Text nodes so standard DOM Range objects can be created
|
|
9
|
+
* and registered with CSS.highlights, delivering on-the-fly syntax highlighting.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isOpaqueRangeSupported, isHighlightSupported } from './highlights.js';
|
|
13
|
+
|
|
14
|
+
export const isContentEditableFallbackActive = !isOpaqueRangeSupported && isHighlightSupported;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Calculates character offset of a container + offset within an element.
|
|
18
|
+
*
|
|
19
|
+
* @param {HTMLElement} el
|
|
20
|
+
* @param {Node} container
|
|
21
|
+
* @param {number} offset
|
|
22
|
+
* @returns {number|null}
|
|
23
|
+
*/
|
|
24
|
+
export function getOffsetInElement(el, container, offset) {
|
|
25
|
+
if (!el || !container) return null;
|
|
26
|
+
if (!el.contains(container) && el !== container) return null;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const range = document.createRange();
|
|
30
|
+
range.selectNodeContents(el);
|
|
31
|
+
range.setEnd(container, offset);
|
|
32
|
+
return range.toString().length;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derives caret character offset directly from viewport client coordinates.
|
|
40
|
+
* Uses WebKit document.caretRangeFromPoint and W3C document.caretPositionFromPoint.
|
|
41
|
+
*
|
|
42
|
+
* @param {HTMLElement} el
|
|
43
|
+
* @param {number} clientX
|
|
44
|
+
* @param {number} clientY
|
|
45
|
+
* @returns {number|null}
|
|
46
|
+
*/
|
|
47
|
+
export function getCaretOffsetFromPoint(el, clientX, clientY) {
|
|
48
|
+
if (typeof document === 'undefined') return null;
|
|
49
|
+
|
|
50
|
+
// 1. WebKit/Blink standard: document.caretRangeFromPoint (supported in Safari)
|
|
51
|
+
if (typeof document.caretRangeFromPoint === 'function') {
|
|
52
|
+
try {
|
|
53
|
+
const range = document.caretRangeFromPoint(clientX, clientY);
|
|
54
|
+
if (range && range.startContainer) {
|
|
55
|
+
const offset = getOffsetInElement(el, range.startContainer, range.startOffset);
|
|
56
|
+
if (offset !== null) return offset;
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. W3C standard: document.caretPositionFromPoint (supported in Firefox & modern WebKit)
|
|
62
|
+
if (typeof document.caretPositionFromPoint === 'function') {
|
|
63
|
+
try {
|
|
64
|
+
const pos = document.caretPositionFromPoint(clientX, clientY);
|
|
65
|
+
if (pos && pos.offsetNode) {
|
|
66
|
+
const offset = getOffsetInElement(el, pos.offsetNode, pos.offset);
|
|
67
|
+
if (offset !== null) return offset;
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Gets the current character caret offset inside a contenteditable element.
|
|
77
|
+
* Pierces Shadow DOM boundaries via shadowRoot.getSelection() and Selection.getComposedRanges().
|
|
78
|
+
*
|
|
79
|
+
* @param {HTMLElement} el
|
|
80
|
+
* @param {boolean} isStart Whether to get selection start (true) or end (false)
|
|
81
|
+
* @returns {number}
|
|
82
|
+
*/
|
|
83
|
+
export function getCaretCharacterOffset(el, isStart = true) {
|
|
84
|
+
if (typeof window === 'undefined') return 0;
|
|
85
|
+
|
|
86
|
+
const root = typeof el.getRootNode === 'function' ? el.getRootNode() : null;
|
|
87
|
+
let range = null;
|
|
88
|
+
|
|
89
|
+
// 1. Try ShadowRoot.getSelection() if supported
|
|
90
|
+
if (root && typeof root.getSelection === 'function') {
|
|
91
|
+
try {
|
|
92
|
+
const shadowSel = root.getSelection();
|
|
93
|
+
if (shadowSel && shadowSel.rangeCount > 0) {
|
|
94
|
+
range = shadowSel.getRangeAt(0);
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2. Try window.getSelection().getComposedRanges() (W3C standard in Safari 17+, Chrome)
|
|
100
|
+
const sel = window.getSelection ? window.getSelection() : null;
|
|
101
|
+
if (!range && sel && typeof sel.getComposedRanges === 'function' && root) {
|
|
102
|
+
try {
|
|
103
|
+
const composed = sel.getComposedRanges({ shadowRoots: [root] });
|
|
104
|
+
if (composed && composed.length > 0) {
|
|
105
|
+
range = composed[0];
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. Fallback to standard window.getSelection()
|
|
111
|
+
if (!range && sel && sel.rangeCount > 0) {
|
|
112
|
+
try {
|
|
113
|
+
range = sel.getRangeAt(0);
|
|
114
|
+
} catch (e) {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (range) {
|
|
118
|
+
const container = isStart ? range.startContainer : range.endContainer;
|
|
119
|
+
const offset = isStart ? range.startOffset : range.endOffset;
|
|
120
|
+
const pos = getOffsetInElement(el, container, offset);
|
|
121
|
+
if (pos !== null) {
|
|
122
|
+
el._lastCaretOffset = pos;
|
|
123
|
+
return pos;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return typeof el._lastCaretOffset === 'number' ? el._lastCaretOffset : 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sets the caret or selection character range inside a contenteditable element.
|
|
132
|
+
*
|
|
133
|
+
* @param {HTMLElement} el
|
|
134
|
+
* @param {number} start
|
|
135
|
+
* @param {number} [end]
|
|
136
|
+
*/
|
|
137
|
+
export function setCaretCharacterOffset(el, start, end = start) {
|
|
138
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
139
|
+
|
|
140
|
+
const textNode = getSingleTextNode(el);
|
|
141
|
+
const len = (textNode.textContent || '').length;
|
|
142
|
+
const s = Math.max(0, Math.min(start ?? 0, len));
|
|
143
|
+
const e = Math.max(0, Math.min(end ?? s, len));
|
|
144
|
+
|
|
145
|
+
el._lastCaretOffset = s;
|
|
146
|
+
|
|
147
|
+
const root = typeof el.getRootNode === 'function' ? el.getRootNode() : null;
|
|
148
|
+
const sel = (root && typeof root.getSelection === 'function') ? root.getSelection() : window.getSelection();
|
|
149
|
+
if (!sel) return;
|
|
150
|
+
|
|
151
|
+
// setBaseAndExtent works natively across WebKit, Gecko, and Blink
|
|
152
|
+
if (typeof sel.setBaseAndExtent === 'function') {
|
|
153
|
+
try {
|
|
154
|
+
sel.setBaseAndExtent(textNode, s, textNode, e);
|
|
155
|
+
return;
|
|
156
|
+
} catch (err) {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const range = document.createRange();
|
|
161
|
+
range.setStart(textNode, s);
|
|
162
|
+
range.setEnd(textNode, e);
|
|
163
|
+
sel.removeAllRanges();
|
|
164
|
+
sel.addRange(range);
|
|
165
|
+
} catch (err) {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Ensures the element has exactly one child Text node and returns it.
|
|
170
|
+
*
|
|
171
|
+
* @param {HTMLElement} el
|
|
172
|
+
* @returns {Text}
|
|
173
|
+
*/
|
|
174
|
+
export function getSingleTextNode(el) {
|
|
175
|
+
if (!el.firstChild) {
|
|
176
|
+
const tn = document.createTextNode('');
|
|
177
|
+
el.appendChild(tn);
|
|
178
|
+
return tn;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (el.childNodes.length === 1 && el.firstChild.nodeType === 3) {
|
|
182
|
+
return el.firstChild;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
el.normalize();
|
|
186
|
+
if (el.childNodes.length === 1 && el.firstChild.nodeType === 3) {
|
|
187
|
+
return el.firstChild;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Strip elements (<br>, <div>) inserted by contenteditable
|
|
191
|
+
const text = el.textContent || '';
|
|
192
|
+
el.textContent = text;
|
|
193
|
+
return el.firstChild || el.appendChild(document.createTextNode(''));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Configures an HTMLElement as a contenteditable input adapter.
|
|
198
|
+
*
|
|
199
|
+
* @param {HTMLElement} el
|
|
200
|
+
* @param {HTMLElement} host
|
|
201
|
+
* @returns {HTMLElement}
|
|
202
|
+
*/
|
|
203
|
+
export function setupContentEditableAdapter(el, host) {
|
|
204
|
+
el._lastCaretOffset = 0;
|
|
205
|
+
|
|
206
|
+
// 1. Enforce plaintext-only editing (Firefox falls back to true)
|
|
207
|
+
el.setAttribute('contenteditable', 'plaintext-only');
|
|
208
|
+
if (el.contentEditable !== 'plaintext-only') {
|
|
209
|
+
el.setAttribute('contenteditable', 'true');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2. Track caret offset on mouse click, pointer, keyboard, and selection interactions
|
|
213
|
+
const updateCaret = (e) => {
|
|
214
|
+
if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
|
|
215
|
+
const offset = getCaretOffsetFromPoint(el, e.clientX, e.clientY);
|
|
216
|
+
if (offset !== null) {
|
|
217
|
+
el._lastCaretOffset = offset;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const fromSel = getCaretCharacterOffset(el, true);
|
|
222
|
+
if (typeof fromSel === 'number') {
|
|
223
|
+
el._lastCaretOffset = fromSel;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
el.addEventListener('pointerdown', updateCaret);
|
|
228
|
+
el.addEventListener('mousedown', updateCaret);
|
|
229
|
+
el.addEventListener('pointerup', updateCaret);
|
|
230
|
+
el.addEventListener('mouseup', updateCaret);
|
|
231
|
+
el.addEventListener('click', updateCaret);
|
|
232
|
+
el.addEventListener('keyup', updateCaret);
|
|
233
|
+
|
|
234
|
+
if (typeof document !== 'undefined') {
|
|
235
|
+
const onSelectionChange = () => {
|
|
236
|
+
const root = typeof el.getRootNode === 'function' ? el.getRootNode() : null;
|
|
237
|
+
const activeEl = root && root.activeElement ? root.activeElement : document.activeElement;
|
|
238
|
+
if (activeEl === el || el.contains(activeEl)) {
|
|
239
|
+
const offset = getCaretCharacterOffset(el, true);
|
|
240
|
+
if (typeof offset === 'number') {
|
|
241
|
+
el._lastCaretOffset = offset;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
document.addEventListener('selectionchange', onSelectionChange);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 3. Prevent linebreaks on Enter (single-line input behavior)
|
|
249
|
+
el.addEventListener('keydown', (e) => {
|
|
250
|
+
if (e.key === 'Enter') {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// 5. Strip linebreaks from pasted content
|
|
256
|
+
el.addEventListener('paste', (e) => {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
const clipboard = e.clipboardData || window.clipboardData;
|
|
259
|
+
const text = (clipboard?.getData('text/plain') || '').replace(/\r?\n/g, ' ');
|
|
260
|
+
if (text) {
|
|
261
|
+
if (typeof document.execCommand === 'function') {
|
|
262
|
+
document.execCommand('insertText', false, text);
|
|
263
|
+
} else {
|
|
264
|
+
const start = el.selectionStart;
|
|
265
|
+
const current = el.textContent || '';
|
|
266
|
+
el.textContent = current.slice(0, start) + text + current.slice(el.selectionEnd);
|
|
267
|
+
setCaretCharacterOffset(el, start + text.length);
|
|
268
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// 6. Clean empty element so :empty matches data-placeholder
|
|
274
|
+
el.addEventListener('input', () => {
|
|
275
|
+
if (!el.textContent) {
|
|
276
|
+
el.replaceChildren();
|
|
277
|
+
el._lastCaretOffset = 0;
|
|
278
|
+
} else {
|
|
279
|
+
el._lastCaretOffset = getCaretCharacterOffset(el, true);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// 7. Define HTMLInputElement-compatible properties and methods
|
|
284
|
+
Object.defineProperty(el, 'value', {
|
|
285
|
+
get() {
|
|
286
|
+
return el.textContent || '';
|
|
287
|
+
},
|
|
288
|
+
set(val) {
|
|
289
|
+
const next = val || '';
|
|
290
|
+
if (el.textContent !== next) {
|
|
291
|
+
el.textContent = next;
|
|
292
|
+
el._lastCaretOffset = Math.min(el._lastCaretOffset || 0, next.length);
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
configurable: true,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
Object.defineProperty(el, 'placeholder', {
|
|
299
|
+
get() {
|
|
300
|
+
return el.getAttribute('data-placeholder') || '';
|
|
301
|
+
},
|
|
302
|
+
set(val) {
|
|
303
|
+
if (val) {
|
|
304
|
+
el.setAttribute('data-placeholder', val);
|
|
305
|
+
} else {
|
|
306
|
+
el.removeAttribute('data-placeholder');
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
configurable: true,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
Object.defineProperty(el, 'disabled', {
|
|
313
|
+
get() {
|
|
314
|
+
return el.hasAttribute('disabled') || el.contentEditable === 'false';
|
|
315
|
+
},
|
|
316
|
+
set(val) {
|
|
317
|
+
if (val) {
|
|
318
|
+
el.setAttribute('disabled', '');
|
|
319
|
+
el.setAttribute('contenteditable', 'false');
|
|
320
|
+
} else {
|
|
321
|
+
el.removeAttribute('disabled');
|
|
322
|
+
el.setAttribute('contenteditable', 'plaintext-only');
|
|
323
|
+
if (el.contentEditable !== 'plaintext-only') {
|
|
324
|
+
el.setAttribute('contenteditable', 'true');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
configurable: true,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
Object.defineProperty(el, 'readOnly', {
|
|
332
|
+
get() {
|
|
333
|
+
return el.contentEditable === 'false';
|
|
334
|
+
},
|
|
335
|
+
set(val) {
|
|
336
|
+
if (val) {
|
|
337
|
+
el.setAttribute('contenteditable', 'false');
|
|
338
|
+
} else {
|
|
339
|
+
el.setAttribute('contenteditable', 'plaintext-only');
|
|
340
|
+
if (el.contentEditable !== 'plaintext-only') {
|
|
341
|
+
el.setAttribute('contenteditable', 'true');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
configurable: true,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
Object.defineProperty(el, 'selectionStart', {
|
|
349
|
+
get() {
|
|
350
|
+
return getCaretCharacterOffset(el, true);
|
|
351
|
+
},
|
|
352
|
+
set(val) {
|
|
353
|
+
const currentEnd = getCaretCharacterOffset(el, false);
|
|
354
|
+
const nextEnd = Math.max(val, currentEnd);
|
|
355
|
+
setCaretCharacterOffset(el, val, nextEnd);
|
|
356
|
+
},
|
|
357
|
+
configurable: true,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
Object.defineProperty(el, 'selectionEnd', {
|
|
361
|
+
get() {
|
|
362
|
+
return getCaretCharacterOffset(el, false);
|
|
363
|
+
},
|
|
364
|
+
set(val) {
|
|
365
|
+
const currentStart = getCaretCharacterOffset(el, true);
|
|
366
|
+
const nextStart = Math.min(val, currentStart);
|
|
367
|
+
setCaretCharacterOffset(el, nextStart, val);
|
|
368
|
+
},
|
|
369
|
+
configurable: true,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
Object.defineProperty(el, 'selectionDirection', {
|
|
373
|
+
value: 'forward',
|
|
374
|
+
writable: true,
|
|
375
|
+
configurable: true,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
el.setSelectionRange = function (start, end) {
|
|
379
|
+
setCaretCharacterOffset(el, start, end);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
el.select = function () {
|
|
383
|
+
setCaretCharacterOffset(el, 0, (el.textContent || '').length);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
el.updateCaretFromPoint = function (clientX, clientY) {
|
|
387
|
+
if (typeof clientX === 'number' && typeof clientY === 'number') {
|
|
388
|
+
const offset = getCaretOffsetFromPoint(el, clientX, clientY);
|
|
389
|
+
if (offset !== null) {
|
|
390
|
+
el._lastCaretOffset = offset;
|
|
391
|
+
return el._lastCaretOffset;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const offset = getCaretCharacterOffset(el, true);
|
|
395
|
+
if (typeof offset === 'number') {
|
|
396
|
+
el._lastCaretOffset = offset;
|
|
397
|
+
}
|
|
398
|
+
return el._lastCaretOffset;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// 8. createValueRange implementation returning a standard DOM Range over the text node
|
|
402
|
+
el.createValueRange = function (start, end) {
|
|
403
|
+
const textNode = getSingleTextNode(el);
|
|
404
|
+
const len = (textNode.textContent || '').length;
|
|
405
|
+
const s = Math.max(0, Math.min(start ?? 0, len));
|
|
406
|
+
const e = Math.max(0, Math.min(end ?? s, len));
|
|
407
|
+
|
|
408
|
+
const range = new Range();
|
|
409
|
+
range.setStart(textNode, s);
|
|
410
|
+
range.setEnd(textNode, e);
|
|
411
|
+
range.disconnect = () => {};
|
|
412
|
+
return range;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
el.checkValidity = () => true;
|
|
416
|
+
el.reportValidity = () => true;
|
|
417
|
+
el.validity = { valid: true };
|
|
418
|
+
el.validationMessage = '';
|
|
419
|
+
|
|
420
|
+
return el;
|
|
421
|
+
}
|
package/utils/highlights.js
CHANGED
|
@@ -13,6 +13,17 @@ export const isHighlightSupported =
|
|
|
13
13
|
typeof CSS !== 'undefined' &&
|
|
14
14
|
'highlights' in CSS;
|
|
15
15
|
|
|
16
|
+
function isRangeCollapsed(range) {
|
|
17
|
+
if (!range) return true;
|
|
18
|
+
if (range.collapsed === true) return true;
|
|
19
|
+
if (typeof range.startOffset === 'number' && typeof range.endOffset === 'number') {
|
|
20
|
+
if (range.startOffset === range.endOffset && range.startContainer === range.endContainer) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
class HighlightRegistryManager {
|
|
17
28
|
constructor() {
|
|
18
29
|
this.instances = new Set();
|
|
@@ -35,7 +46,7 @@ class HighlightRegistryManager {
|
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
syncAll() {
|
|
38
|
-
if (!isHighlightSupported
|
|
49
|
+
if (!isHighlightSupported) return;
|
|
39
50
|
|
|
40
51
|
// Aggregate ranges across all active instances
|
|
41
52
|
const rangesByKeyword = new Map();
|
|
@@ -65,35 +76,58 @@ class HighlightRegistryManager {
|
|
|
65
76
|
|
|
66
77
|
// 1. Set/update individual keyword highlights (e.g. ::highlight(label))
|
|
67
78
|
for (const kw of this.registeredKeywords) {
|
|
68
|
-
const ranges = rangesByKeyword.get(kw);
|
|
69
|
-
|
|
79
|
+
const ranges = rangesByKeyword.get(kw) || [];
|
|
80
|
+
let hl = CSS.highlights.get(kw);
|
|
81
|
+
if (!hl) {
|
|
70
82
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
hl = new Highlight();
|
|
84
|
+
CSS.highlights.set(kw, hl);
|
|
85
|
+
} catch (e) {}
|
|
86
|
+
}
|
|
87
|
+
if (hl) {
|
|
88
|
+
hl.clear();
|
|
89
|
+
for (const r of ranges) {
|
|
90
|
+
try {
|
|
91
|
+
if (isRangeCollapsed(r)) continue;
|
|
92
|
+
hl.add(r);
|
|
93
|
+
} catch (e) {}
|
|
74
94
|
}
|
|
75
|
-
} else {
|
|
76
|
-
CSS.highlights.delete(kw);
|
|
77
95
|
}
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
// 2. Set generic highlights
|
|
81
|
-
|
|
99
|
+
let kwHl = CSS.highlights.get('rich-input-keyword');
|
|
100
|
+
if (!kwHl) {
|
|
82
101
|
try {
|
|
83
|
-
|
|
102
|
+
kwHl = new Highlight();
|
|
84
103
|
CSS.highlights.set('rich-input-keyword', kwHl);
|
|
85
104
|
} catch (e) {}
|
|
86
|
-
}
|
|
87
|
-
|
|
105
|
+
}
|
|
106
|
+
if (kwHl) {
|
|
107
|
+
kwHl.clear();
|
|
108
|
+
for (const r of allKeywordRanges) {
|
|
109
|
+
try {
|
|
110
|
+
if (isRangeCollapsed(r)) continue;
|
|
111
|
+
kwHl.add(r);
|
|
112
|
+
} catch (e) {}
|
|
113
|
+
}
|
|
88
114
|
}
|
|
89
115
|
|
|
90
|
-
|
|
116
|
+
let valHl = CSS.highlights.get('rich-input-value');
|
|
117
|
+
if (!valHl) {
|
|
91
118
|
try {
|
|
92
|
-
|
|
119
|
+
valHl = new Highlight();
|
|
93
120
|
CSS.highlights.set('rich-input-value', valHl);
|
|
94
121
|
} catch (e) {}
|
|
95
|
-
}
|
|
96
|
-
|
|
122
|
+
}
|
|
123
|
+
if (valHl) {
|
|
124
|
+
valHl.clear();
|
|
125
|
+
for (const r of allValueRanges) {
|
|
126
|
+
try {
|
|
127
|
+
if (isRangeCollapsed(r)) continue;
|
|
128
|
+
valHl.add(r);
|
|
129
|
+
} catch (e) {}
|
|
130
|
+
}
|
|
97
131
|
}
|
|
98
132
|
}
|
|
99
133
|
}
|
package/utils/positioning.js
CHANGED
|
@@ -1,8 +1,108 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Caret measurement and popover positioning for <rich-input>
|
|
3
|
-
* Uses OpaqueRange.getBoundingClientRect() with
|
|
3
|
+
* Uses OpaqueRange.getBoundingClientRect() with hidden mirror div fallback.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { isOpaqueRangeSupported } from './highlights.js';
|
|
7
|
+
|
|
8
|
+
let mirrorDiv = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Gets or creates the reusable hidden mirror div for measuring text width in the input.
|
|
12
|
+
* @returns {HTMLDivElement|null}
|
|
13
|
+
*/
|
|
14
|
+
function getMirrorDiv() {
|
|
15
|
+
if (typeof document === 'undefined') return null;
|
|
16
|
+
|
|
17
|
+
if (!mirrorDiv || !mirrorDiv.isConnected) {
|
|
18
|
+
mirrorDiv = document.createElement('div');
|
|
19
|
+
mirrorDiv.id = 'rich-input-mirror';
|
|
20
|
+
mirrorDiv.setAttribute('aria-hidden', 'true');
|
|
21
|
+
mirrorDiv.style.position = 'absolute';
|
|
22
|
+
mirrorDiv.style.top = '-9999px';
|
|
23
|
+
mirrorDiv.style.left = '-9999px';
|
|
24
|
+
mirrorDiv.style.visibility = 'hidden';
|
|
25
|
+
mirrorDiv.style.pointerEvents = 'none';
|
|
26
|
+
mirrorDiv.style.whiteSpace = 'pre';
|
|
27
|
+
mirrorDiv.style.display = 'inline-block';
|
|
28
|
+
mirrorDiv.style.width = 'auto';
|
|
29
|
+
mirrorDiv.style.height = 'auto';
|
|
30
|
+
mirrorDiv.style.margin = '0';
|
|
31
|
+
mirrorDiv.style.padding = '0';
|
|
32
|
+
mirrorDiv.style.border = '0';
|
|
33
|
+
(document.body || document.documentElement).appendChild(mirrorDiv);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return mirrorDiv;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fallback measurement using a hidden mirror div for browsers that don't support OpaqueRange.
|
|
41
|
+
* Sets the div's text content to the input's partial text (or full text) up to the target
|
|
42
|
+
* character offset to figure out the left position to use for the popover.
|
|
43
|
+
*
|
|
44
|
+
* @param {HTMLInputElement} input
|
|
45
|
+
* @param {number|OpaqueRange} [target] Character offset or range
|
|
46
|
+
* @returns {number} Viewport-relative left position in pixels
|
|
47
|
+
*/
|
|
48
|
+
export function getCaretLeftWithMirrorDiv(input, target) {
|
|
49
|
+
const inputRect = input.getBoundingClientRect();
|
|
50
|
+
const value = input.value || '';
|
|
51
|
+
|
|
52
|
+
// Determine character offset
|
|
53
|
+
let offset = value.length;
|
|
54
|
+
if (typeof target === 'number') {
|
|
55
|
+
offset = target;
|
|
56
|
+
} else if (target && typeof target.startOffset === 'number') {
|
|
57
|
+
offset = target.startOffset;
|
|
58
|
+
} else if (typeof input.selectionStart === 'number') {
|
|
59
|
+
offset = input.selectionStart;
|
|
60
|
+
}
|
|
61
|
+
offset = Math.max(0, Math.min(offset, value.length));
|
|
62
|
+
|
|
63
|
+
// Input's text or partial text up to target offset
|
|
64
|
+
const textContent = value.slice(0, offset);
|
|
65
|
+
|
|
66
|
+
const mirror = getMirrorDiv();
|
|
67
|
+
if (!mirror) {
|
|
68
|
+
return inputRect.left;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Copy computed font and text layout styles from the input
|
|
72
|
+
const computed = window.getComputedStyle(input);
|
|
73
|
+
mirror.style.fontFamily = computed.fontFamily;
|
|
74
|
+
mirror.style.fontSize = computed.fontSize;
|
|
75
|
+
mirror.style.fontWeight = computed.fontWeight;
|
|
76
|
+
mirror.style.fontStyle = computed.fontStyle;
|
|
77
|
+
mirror.style.fontVariant = computed.fontVariant;
|
|
78
|
+
mirror.style.fontStretch = computed.fontStretch;
|
|
79
|
+
mirror.style.letterSpacing = computed.letterSpacing;
|
|
80
|
+
mirror.style.wordSpacing = computed.wordSpacing;
|
|
81
|
+
mirror.style.textTransform = computed.textTransform;
|
|
82
|
+
mirror.style.textIndent = computed.textIndent;
|
|
83
|
+
mirror.style.direction = computed.direction;
|
|
84
|
+
|
|
85
|
+
// Set the text content of the hidden div
|
|
86
|
+
mirror.textContent = textContent;
|
|
87
|
+
|
|
88
|
+
const textWidth = mirror.getBoundingClientRect().width;
|
|
89
|
+
const isRtl = computed.direction === 'rtl';
|
|
90
|
+
|
|
91
|
+
let left;
|
|
92
|
+
if (isRtl) {
|
|
93
|
+
const borderRight = parseFloat(computed.borderRightWidth) || 0;
|
|
94
|
+
const paddingRight = parseFloat(computed.paddingRight) || 0;
|
|
95
|
+
left = inputRect.right - borderRight - paddingRight - textWidth + (input.scrollLeft || 0);
|
|
96
|
+
} else {
|
|
97
|
+
const borderLeft = parseFloat(computed.borderLeftWidth) || 0;
|
|
98
|
+
const paddingLeft = parseFloat(computed.paddingLeft) || 0;
|
|
99
|
+
left = inputRect.left + borderLeft + paddingLeft + textWidth - (input.scrollLeft || 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Clamp within input bounds
|
|
103
|
+
return Math.max(inputRect.left, Math.min(left, inputRect.right));
|
|
104
|
+
}
|
|
105
|
+
|
|
6
106
|
/**
|
|
7
107
|
* Gets caret or range coordinates relative to viewport.
|
|
8
108
|
* Accepts either an OpaqueRange instance or a character offset number.
|
|
@@ -12,6 +112,22 @@
|
|
|
12
112
|
*/
|
|
13
113
|
export function getCaretCoordinates(input, target) {
|
|
14
114
|
const inputRect = input.getBoundingClientRect();
|
|
115
|
+
|
|
116
|
+
// For browsers that don't support OpaqueRange to position the popover,
|
|
117
|
+
// do the trick with a hidden div and setting its text contents to the input's
|
|
118
|
+
// text (or partial text) to figure out the left position to use for the popover.
|
|
119
|
+
if (!isOpaqueRangeSupported) {
|
|
120
|
+
const left = getCaretLeftWithMirrorDiv(input, target);
|
|
121
|
+
return {
|
|
122
|
+
left,
|
|
123
|
+
top: inputRect.top,
|
|
124
|
+
bottom: inputRect.bottom,
|
|
125
|
+
height: inputRect.height,
|
|
126
|
+
isCaret: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// When OpaqueRange is supported, use native OpaqueRange measurement
|
|
15
131
|
let rect = null;
|
|
16
132
|
let tempRange = null;
|
|
17
133
|
|
|
@@ -77,6 +193,11 @@ export const getRangeCoordinates = getCaretCoordinates;
|
|
|
77
193
|
export function positionPopover(popover, caretCoords, inputElement) {
|
|
78
194
|
if (!popover) return;
|
|
79
195
|
|
|
196
|
+
if (!caretCoords && inputElement) {
|
|
197
|
+
caretCoords = getCaretCoordinates(inputElement);
|
|
198
|
+
}
|
|
199
|
+
if (!caretCoords) return;
|
|
200
|
+
|
|
80
201
|
const popoverWidth = popover.offsetWidth || 280;
|
|
81
202
|
const popoverHeight = popover.offsetHeight || 220;
|
|
82
203
|
|