Skip to content

Commit 710c099

Browse files
committed
Replace custom element with plain class API
1 parent 5e51f15 commit 710c099

File tree

8 files changed

+99
-130
lines changed

8 files changed

+99
-130
lines changed

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,18 @@ This API is focused on providing an intuitive way to obtain the coordinates of t
4949
- Two new manipulation methods are present instead: [`setStartOffset`](https://iansan5653.github.io/dom-input-range/classes/InputRange.html#setStartOffset) and [`setEndOffset`](https://iansan5653.github.io/dom-input-range/classes/InputRange.html#setEndOffset)
5050
- Methods that modify the range contents are not implemented - work with the input `value` directly instead
5151

52-
## Implementation and performance considerations
52+
## `InputStyleClone` low-level API
5353

54-
Behind the scenes, `InputRange` works by creating a 'clone' element that copies all of the styling and contents from the input element. This clone is then appended to the document and hidden from view so it can be queried. This low-level API is exposed as [`InputStyleCloneElement`](https://iansan5653.github.io/dom-input-range/classes/InputStyleCloneElement.html) for advanced use cases.
54+
Behind the scenes, `InputRange` works by creating a 'clone' element that copies all of the styling and contents from the input element. This clone is then appended to the document and hidden from view so it can be queried. This low-level API is exposed as [`InputStyleClone`](https://iansan5653.github.io/dom-input-range/classes/InputStyleClone.html) for advanced use cases:
5555

56-
Mounting a new element and copying styles can have a real performance impact, and this API has been carefully designed to minimize that. The clone element is only created once per input element, and is reused for all subsequent queries — even if new `InputRange` instances are constructed. The clone element is automatically discarded after it is not queried for a while.
56+
```ts
57+
const clone = new InputStyleClone(input)
58+
clone.element.getBoundingClientRect()
59+
```
5760

58-
There is practically no overhead to constructing new `InputRange` instances - whether or not you reuse them is entirely up to what best fits with your usage.
61+
Mounting a new element and copying styles can have a real performance impact, and this API has been carefully designed to minimize that. You can use `InputStyleClone.for` to share a single default clone instance for the lifetime of an input, **if you only plan to query and not mutate the clone element**:
5962

60-
If you do notice any performance issues, please [create a new issue](https://github.com/iansan5653/dom-input-range/issues).
63+
```ts
64+
const sharedClone = InputStyleClone.for(input)
65+
clone.element.getBoundingClientRect()
66+
```

demos/caret/caret.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { InputRange } from "../../src/input-range.js";
2-
import { InputStyleCloneElement } from "../../src/input-style-clone-element.js";
2+
import { InputStyleClone } from "../../src/input-style-clone.js";
33

44
const inputs = document.querySelectorAll<HTMLTextAreaElement | HTMLInputElement>(".caret-input");
55

@@ -50,5 +50,5 @@ for (const input of inputs) {
5050
// mouseup for dragging
5151
input.addEventListener("mouseup", () => setTimeout(() => updateIndicator()));
5252
input.addEventListener("blur", () => hideIndicator());
53-
InputStyleCloneElement.for(input).addEventListener("update", () => updateIndicator());
53+
InputStyleClone.for(input).addEventListener("update", () => updateIndicator());
5454
}

demos/playground/playground.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { InputRange } from "../../src/input-range.js";
2-
import { InputStyleCloneElement } from "../../src/input-style-clone-element.js";
2+
import { InputStyleClone } from "../../src/input-style-clone.js";
33

44
const textInput = document.getElementById("input") as HTMLTextAreaElement;
55
const startOffsetInput = document.getElementById("start") as HTMLInputElement;
@@ -57,6 +57,6 @@ function draw() {
5757

5858
draw();
5959

60-
InputStyleCloneElement.for(textInput).addEventListener("update", () => draw());
61-
InputStyleCloneElement.for(startOffsetInput).addEventListener("update", () => draw());
62-
InputStyleCloneElement.for(endOffsetInput).addEventListener("update", () => draw());
60+
InputStyleClone.for(textInput).addEventListener("update", () => draw());
61+
InputStyleClone.for(startOffsetInput).addEventListener("update", () => draw());
62+
InputStyleClone.for(endOffsetInput).addEventListener("update", () => draw());

demos/words/words.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { InputRange } from "../../src/input-range.js";
2-
import { InputStyleCloneElement } from "../../src/input-style-clone-element.js";
2+
import { InputStyleClone } from "../../src/input-style-clone.js";
33

44
const inputs = document.querySelectorAll<HTMLTextAreaElement | HTMLInputElement>(".words-input");
55

@@ -47,7 +47,7 @@ function createHighlights() {
4747
createHighlights();
4848

4949
for (const input of inputs)
50-
InputStyleCloneElement.for(input).addEventListener("update", () => {
50+
InputStyleClone.for(input).addEventListener("update", () => {
5151
// use a timeout to let the input change first
5252
setTimeout(() => {
5353
clearHighlights();

src/custom-html-element.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export * from "./input-range.js";
2-
export * from "./input-style-clone-element.js";
2+
export * from "./input-style-clone.js";

src/input-range.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { type InputElement, InputStyleCloneElement } from "./input-style-clone-element.js";
1+
import { type InputElement, InputStyleClone } from "./input-style-clone.js";
22

3-
export type { InputElement } from "./input-style-clone-element.js";
3+
export type { InputElement } from "./input-style-clone.js";
44

55
/**
66
* A fragment of a document that can contain only pieces of a single text node. Does not implement `Range` methods
@@ -138,14 +138,14 @@ export class InputRange implements ReadonlyTextRange {
138138
* Get the underlying `InputStyleClone` instance powering these calculations. This can be used to listen for
139139
* updates to trigger layout recalculation.
140140
*/
141-
getStyleClone(): InputStyleCloneElement {
141+
getStyleClone(): InputStyleClone {
142142
return this.#styleClone;
143143
}
144144

145145
// --- private ---
146146

147147
get #styleClone() {
148-
return InputStyleCloneElement.for(this.#inputElement);
148+
return InputStyleClone.for(this.#inputElement);
149149
}
150150

151151
get #cloneElement() {
@@ -162,7 +162,7 @@ export class InputRange implements ReadonlyTextRange {
162162
// must create a new range every time we need it.
163163
const range = document.createRange();
164164

165-
const textNode = this.#cloneElement.childNodes[0];
165+
const textNode = this.#cloneElement.element.childNodes[0];
166166
if (textNode) {
167167
range.setStart(textNode, this.startOffset);
168168
range.setEnd(textNode, this.endOffset);
Lines changed: 74 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { CustomHTMLElement } from "./custom-html-element.js";
2-
31
export type InputElement = HTMLTextAreaElement | HTMLInputElement;
42

53
export class InputStyleCloneUpdateEvent extends Event {
@@ -8,10 +6,10 @@ export class InputStyleCloneUpdateEvent extends Event {
86
}
97
}
108

11-
const CloneRegistry = new WeakMap<InputElement, InputStyleCloneElement>();
9+
const CloneRegistry = new WeakMap<InputElement, InputStyleClone>();
1210

1311
/**
14-
* Create an element that exactly matches an input pixel-for-pixel and automatically stays in sync with it. This
12+
* Creates an element that exactly matches an input pixel-for-pixel and automatically stays in sync with it. This
1513
* is a non-interactive overlay on to the input and can be used to affect the visual appearance of the input
1614
* without modifying its behavior. The clone element is hidden by default.
1715
*
@@ -28,7 +26,7 @@ const CloneRegistry = new WeakMap<InputElement, InputStyleCloneElement>();
2826
// - primer/react (Copyright (c) 2018 GitHub, Inc.): https://github.com/primer/react/blob/a0db832302702b869aa22b0c4049ad9305ef631f/src/drafts/utils/character-coordinates.ts
2927
// - component/textarea-caret-position (Copyright (c) 2015 Jonathan Ong me@jongleberry.com): https://github.com/component/textarea-caret-position/blob/b5db7a7e47dd149c2a66276183c69234e4dabe30/index.js
3028
// - koddsson/textarea-caret-position (Copyright (c) 2015 Jonathan Ong me@jongleberry.com): https://github.com/koddsson/textarea-caret-position/blob/eba40ec8488eed4d77815f109af22e1d9c0751d3/index.js
31-
export class InputStyleCloneElement extends CustomHTMLElement {
29+
export class InputStyleClone extends EventTarget {
3230
#styleObserver = new MutationObserver(() => this.#updateStyles());
3331
#resizeObserver = new ResizeObserver(() => this.#requestUpdateLayout());
3432

@@ -37,23 +35,23 @@ export class InputStyleCloneElement extends CustomHTMLElement {
3735
// preventing the garbage collection of the associated input. This also allows us to automatically detach if the
3836
// input gets collected.
3937
#inputRef?: WeakRef<InputElement>;
40-
#container?: HTMLDivElement;
38+
#container = document.createElement("div");
39+
#cloneElement = document.createElement("div");
4140

4241
/**
4342
* Get the clone for an input, reusing an existing one if available. This avoids creating unecessary clones, which
4443
* have a performance cost due to their high-frequency event-based updates. Because these elements are shared, they
45-
* should be mutated with caution.
44+
* should be mutated with caution. If you're planning to mutate the clone, consider constructing a new one instead.
4645
*
4746
* Upon initial creation the clone element will automatically be inserted into the DOM and begin observing the
48-
* linked input. Only one clone per input can ever exist at a time.
47+
* linked input.
4948
* @param input The target input to clone.
5049
*/
5150
static for(input: InputElement) {
5251
let clone = CloneRegistry.get(input);
5352

5453
if (!clone) {
55-
clone = new InputStyleCloneElement();
56-
clone.connect(input);
54+
clone = new InputStyleClone(input);
5755
CloneRegistry.set(input, clone);
5856
}
5957

@@ -63,21 +61,63 @@ export class InputStyleCloneElement extends CustomHTMLElement {
6361
/**
6462
* Connect this instance to a target input element and insert this instance into the DOM in the correct location.
6563
*
66-
* NOTE: calling the static `for` method is nearly always preferable as it will reuse an existing clone if available.
64+
* NOTE: calling the static `for` method is usually preferable as it will reuse an existing clone if available.
6765
* However, if reusing clones is problematic (ie, if the clone needs to be mutated), a clone can be constructed
68-
* directly with `new InputStyleCloneElement()` and then bound to an input and inserted into the DOM with
69-
* `clone.connect(target)`.
66+
* directly with `new InputStyleClone(target)`.
7067
*/
71-
connect(input: InputElement) {
68+
constructor(input: InputElement) {
69+
super();
70+
7271
this.#inputRef = new WeakRef(input);
7372

7473
// We want position:absolute so it doesn't take space in the layout, but that doesn't work with display:table-cell
7574
// used in the HTMLInputElement approach. So we need a wrapper.
76-
this.#container = document.createElement("div");
7775
this.#container.style.position = "absolute";
7876
this.#container.style.pointerEvents = "none";
77+
this.#container.setAttribute("aria-hidden", "true");
78+
79+
this.#container.appendChild(this.#cloneElement);
80+
81+
this.#cloneElement.style.pointerEvents = "none";
82+
this.#cloneElement.style.userSelect = "none";
83+
this.#cloneElement.style.overflow = "hidden";
84+
this.#cloneElement.style.display = "block";
85+
86+
// Important not to use display:none which would not render the content at all
87+
this.#cloneElement.style.visibility = "hidden";
88+
89+
if (input instanceof HTMLTextAreaElement) {
90+
this.#cloneElement.style.whiteSpace = "pre-wrap";
91+
this.#cloneElement.style.wordWrap = "break-word";
92+
} else {
93+
this.#cloneElement.style.whiteSpace = "nowrap";
94+
// text in single-line inputs is vertically centered
95+
this.#cloneElement.style.display = "table-cell";
96+
this.#cloneElement.style.verticalAlign = "middle";
97+
}
98+
7999
input.after(this.#container);
80-
this.#container.appendChild(this);
100+
101+
this.#updateStyles();
102+
this.#updateText();
103+
104+
this.#styleObserver.observe(input, {
105+
attributeFilter: [
106+
"style",
107+
"dir", // users can right-click in some browsers to change the text direction dynamically
108+
],
109+
});
110+
this.#resizeObserver.observe(input);
111+
112+
document.addEventListener("scroll", this.#onDocumentScrollOrResize, { capture: true });
113+
window.addEventListener("resize", this.#onDocumentScrollOrResize, { capture: true });
114+
// capture so this happens first, so other things can respond to `input` events after this data updates
115+
input.addEventListener("input", this.#onInput, { capture: true });
116+
}
117+
118+
/** Get the clone element. */
119+
get element() {
120+
return this.#cloneElement;
81121
}
82122

83123
/**
@@ -89,49 +129,7 @@ export class InputStyleCloneElement extends CustomHTMLElement {
89129
this.#updateText();
90130
}
91131

92-
/** @private */
93-
connectedCallback() {
94-
this.#usingInput((input) => {
95-
this.style.pointerEvents = "none";
96-
this.style.userSelect = "none";
97-
this.style.overflow = "hidden";
98-
this.style.display = "block";
99-
100-
// Important not to use display:none which would not render the content at all
101-
this.style.visibility = "hidden";
102-
103-
if (input instanceof HTMLTextAreaElement) {
104-
this.style.whiteSpace = "pre-wrap";
105-
this.style.wordWrap = "break-word";
106-
} else {
107-
this.style.whiteSpace = "nowrap";
108-
// text in single-line inputs is vertically centered
109-
this.style.display = "table-cell";
110-
this.style.verticalAlign = "middle";
111-
}
112-
113-
this.setAttribute("aria-hidden", "true");
114-
115-
this.#updateStyles();
116-
this.#updateText();
117-
118-
this.#styleObserver.observe(input, {
119-
attributeFilter: [
120-
"style",
121-
"dir", // users can right-click in some browsers to change the text direction dynamically
122-
],
123-
});
124-
this.#resizeObserver.observe(input);
125-
126-
document.addEventListener("scroll", this.#onDocumentScrollOrResize, { capture: true });
127-
window.addEventListener("resize", this.#onDocumentScrollOrResize, { capture: true });
128-
// capture so this happens first, so other things can respond to `input` events after this data updates
129-
input.addEventListener("input", this.#onInput, { capture: true });
130-
});
131-
}
132-
133-
/** @private */
134-
disconnectedCallback() {
132+
disconnect() {
135133
this.#container?.remove();
136134
this.#styleObserver.disconnect();
137135
this.#resizeObserver.disconnect();
@@ -155,7 +153,7 @@ export class InputStyleCloneElement extends CustomHTMLElement {
155153
/** Perform `fn` using the `input` if it is still available. If not, clean up the clone instead. */
156154
#usingInput<T>(fn: (input: InputElement) => T | void) {
157155
const input = this.#input;
158-
if (!input) return this.remove();
156+
if (!input) return this.disconnect();
159157
return fn(input);
160158
}
161159

@@ -173,26 +171,30 @@ export class InputStyleCloneElement extends CustomHTMLElement {
173171
this.#usingInput((input) => {
174172
const inputStyle = window.getComputedStyle(input);
175173

176-
this.style.height = inputStyle.height;
177-
this.style.width = inputStyle.width;
174+
this.#cloneElement.style.height = inputStyle.height;
175+
this.#cloneElement.style.width = inputStyle.width;
178176

179177
// Immediately re-adjust for browser inconsistencies in scrollbar handling, if necessary
180-
if (input.clientHeight !== this.clientHeight)
181-
this.style.height = `calc(${inputStyle.height} + ${input.clientHeight - this.clientHeight}px)`;
182-
if (input.clientWidth !== this.clientWidth)
183-
this.style.width = `calc(${inputStyle.width} + ${input.clientWidth - this.clientWidth}px)`;
178+
if (input.clientHeight !== this.#cloneElement.clientHeight)
179+
this.#cloneElement.style.height = `calc(${inputStyle.height} + ${
180+
input.clientHeight - this.#cloneElement.clientHeight
181+
}px)`;
182+
if (input.clientWidth !== this.#cloneElement.clientWidth)
183+
this.#cloneElement.style.width = `calc(${inputStyle.width} + ${
184+
input.clientWidth - this.#cloneElement.clientWidth
185+
}px)`;
184186

185187
// Position on top of the input
186188
const inputRect = input.getBoundingClientRect();
187-
const cloneRect = this.getBoundingClientRect();
189+
const cloneRect = this.#cloneElement.getBoundingClientRect();
188190

189191
this.#xOffset = this.#xOffset + inputRect.left - cloneRect.left;
190192
this.#yOffset = this.#yOffset + inputRect.top - cloneRect.top;
191193

192-
this.style.transform = `translate(${this.#xOffset}px, ${this.#yOffset}px)`;
194+
this.#cloneElement.style.transform = `translate(${this.#xOffset}px, ${this.#yOffset}px)`;
193195

194-
this.scrollTop = input.scrollTop;
195-
this.scrollLeft = input.scrollLeft;
196+
this.#cloneElement.scrollTop = input.scrollTop;
197+
this.#cloneElement.scrollLeft = input.scrollLeft;
196198

197199
this.dispatchEvent(new InputStyleCloneUpdateEvent());
198200
});
@@ -216,7 +218,7 @@ export class InputStyleCloneElement extends CustomHTMLElement {
216218
this.#usingInput((input) => {
217219
const inputStyle = window.getComputedStyle(input);
218220

219-
for (const prop of propertiesToCopy) this.style[prop] = inputStyle[prop];
221+
for (const prop of propertiesToCopy) this.#cloneElement.style[prop] = inputStyle[prop];
220222

221223
this.#requestUpdateLayout();
222224
});
@@ -228,7 +230,7 @@ export class InputStyleCloneElement extends CustomHTMLElement {
228230
*/
229231
#updateText() {
230232
this.#usingInput((input) => {
231-
this.textContent = input.value;
233+
this.#cloneElement.textContent = input.value;
232234

233235
// This is often unecessary on a pure text update, but text updates could potentially cause layout updates like
234236
// scrolling or resizing. And we run the update on _every frame_ when scrolling, so this isn't that expensive.
@@ -296,12 +298,3 @@ const propertiesToCopy = [
296298
"tabSize",
297299
"MozTabSize" as "tabSize", // prefixed version for Firefox <= 52
298300
] as const satisfies ReadonlyArray<keyof CSSStyleDeclaration>;
299-
300-
// Inspired by https://github.com/github/catalyst/blob/dc284dcf4f82329a9cac5c867462a8fa529b6c40/src/register.ts
301-
302-
try {
303-
customElements.define("input-style-clone", InputStyleCloneElement);
304-
} catch (e: unknown) {
305-
// Throws DOMException with NotSupportedError if already defined
306-
if (!(e instanceof DOMException && e.name === "NotSupportedError")) throw e;
307-
}

0 commit comments

Comments
 (0)