Skip to content

Commit 270b400

Browse files
authored
fix: improve stability (#44)
1 parent 3ee2a87 commit 270b400

5 files changed

Lines changed: 206 additions & 69 deletions

File tree

packages/textchecker-element/src/attach-to-text-area.ts

Lines changed: 112 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { TextCheckerCard, TextCheckerPopupElement, TextCheckerPopupElementArgs }
33
import type { TextlintMessage, TextlintResult } from "@textlint/types";
44
import type { TextCheckerElementRectItem } from "./text-checker-store";
55
import pDebounce from "p-debounce";
6-
import delay from "delay";
76
import { debug } from "./util/logger";
87

98
const createCompositionHandler = () => {
@@ -48,15 +47,58 @@ export type AttachTextAreaParams = {
4847
lintEngine: LintEngineAPI;
4948
};
5049

51-
let textCheckerPopup: TextCheckerPopupElement;
5250
const createTextCheckerPopupElement = (args: TextCheckerPopupElementArgs) => {
53-
if (textCheckerPopup) {
54-
return textCheckerPopup;
55-
}
56-
textCheckerPopup = new TextCheckerPopupElement(args);
51+
const textCheckerPopup = new TextCheckerPopupElement(args);
5752
document.body.append(textCheckerPopup);
5853
return textCheckerPopup;
5954
};
55+
56+
/**
57+
* Return true if the element in viewport
58+
* @param element
59+
*/
60+
function isVisibleInViewport(element: HTMLElement): boolean {
61+
const style = window.getComputedStyle(element);
62+
if (style.display === "none" || style.visibility === "hidden") {
63+
return false;
64+
}
65+
const rect = element.getBoundingClientRect();
66+
if (rect.height === 0 || rect.width === 0) {
67+
return false;
68+
}
69+
return (
70+
rect.top >= 0 &&
71+
rect.left >= 0 &&
72+
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
73+
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
74+
);
75+
}
76+
77+
/**
78+
* Dismiss all popup
79+
*
80+
* - Update text(includes fixed)
81+
* - Scroll textarea/Scroll window
82+
* - Focus on textarea
83+
* - Click out of textarea/popup
84+
* - popup → textarea → other → dismiss
85+
* - textarea → popup → other → dismiss
86+
*/
87+
88+
/**
89+
* Dismiss a single popup(500ms delay)
90+
*
91+
* - Leave from popup
92+
* - Leave from RectItem
93+
* - Focus on textarea
94+
*
95+
*/
96+
97+
/**
98+
* Show popup condition
99+
* - onUpdate
100+
*/
101+
60102
/**
61103
* Attach text-checker component to `<textarea>` element
62104
*/
@@ -73,32 +115,45 @@ export const attachToTextArea = ({
73115
debug("Can not attach textarea that is readonly", textAreaElement);
74116
return () => {};
75117
}
76-
const textChecker = new TextCheckerElement({
77-
targetElement: textAreaElement,
78-
hoverPadding: 20
79-
});
80-
textAreaElement.before(textChecker);
81-
const hoverMap = new Map<TextCheckerElementRectItem, boolean>();
118+
if (textAreaElement.dataset.attachedTextCheckerElement === "true") {
119+
debug("Can not attach textarea that is already attached", textAreaElement);
120+
return () => {};
121+
}
82122
const dismissCards = () => {
83-
if (!textCheckerPopup.isHovering && hoverMap.size === 0) {
123+
debug("dismissCards", {
124+
textCheckerPopup: textCheckerPopup.isHovering,
125+
textChecker: textChecker.isHovering,
126+
textCheckerF: textChecker.isFocus
127+
});
128+
if (!textCheckerPopup.isHovering && !textChecker.isHovering && !textChecker.isFocus) {
84129
textCheckerPopup.dismissCards();
130+
textChecker.resetHoverState();
85131
}
86132
};
87133
const textCheckerPopup = createTextCheckerPopupElement({
88134
onLeave() {
89-
if (!textCheckerPopup.isHovering && hoverMap.size === 0) {
90-
textCheckerPopup.dismissCards();
91-
}
135+
dismissCards();
92136
}
93137
});
138+
const textChecker = new TextCheckerElement({
139+
targetElement: textAreaElement,
140+
hoverPadding: 20,
141+
onLeave() {
142+
dismissCards();
143+
}
144+
});
145+
textAreaElement.before(textChecker);
94146
const compositionHandler = createCompositionHandler();
95147
const update = pDebounce(async () => {
148+
if (!isVisibleInViewport(textAreaElement)) {
149+
return;
150+
}
96151
// stop lint on IME composition
97152
if (compositionHandler.onComposition) {
98153
return;
99154
}
100155
// dismiss card before update annotations
101-
textCheckerPopup.dismissCards();
156+
// dismissCards();
102157
const text = textAreaElement.value;
103158
const results = await lintEngine.lintText({
104159
text
@@ -120,19 +175,16 @@ export const attachToTextArea = ({
120175
messageRuleId: message.ruleId,
121176
fixable: Boolean(message.fix)
122177
};
123-
const abortSignalMap = new WeakMap<TextCheckerElementRectItem, AbortController>();
178+
let dismissTimerId: null | any = null;
124179
return {
125180
id: `${message.ruleId}::${message.line}:${message.column}`,
126181
start: message.index,
127182
end: message.index + 1,
128183
onMouseEnter: ({ rectItem }: { rectItem: TextCheckerElementRectItem }) => {
129-
hoverMap.set(rectItem, true);
130-
const controller = abortSignalMap.get(rectItem);
131-
debug("enter", controller);
132-
if (controller) {
133-
controller.abort();
184+
debug("annotation - onMouseEnter");
185+
if (dismissTimerId) {
186+
clearTimeout(dismissTimerId);
134187
}
135-
abortSignalMap.set(rectItem, new AbortController());
136188
textCheckerPopup.updateCard({
137189
card: card,
138190
rect: {
@@ -193,18 +245,20 @@ export const attachToTextArea = ({
193245
},
194246
async onMouseLeave({ rectItem }: { rectItem: TextCheckerElementRectItem }) {
195247
try {
196-
hoverMap.delete(rectItem);
197-
const controller = abortSignalMap.get(rectItem);
198-
debug("leave", controller);
199-
await delay(500, {
200-
signal: controller?.signal
201-
});
202-
if (textCheckerPopup.isHovering || hoverMap.get(rectItem)) {
203-
return;
204-
}
205-
textCheckerPopup.dismissCard(card);
248+
debug("annotation - onMouseLeave");
249+
dismissTimerId = setTimeout(() => {
250+
const isHover = textChecker.isHoverRectItem(rectItem);
251+
debug("dismiss", {
252+
textCheckerPopup: textCheckerPopup.isHovering,
253+
isRectElementHover: isHover
254+
});
255+
if (textCheckerPopup.isHovering || isHover) {
256+
return;
257+
}
258+
textCheckerPopup.dismissCard(card);
259+
}, 500);
206260
} catch (error) {
207-
debug("Abort Canceled", error);
261+
debug("Abort dismiss popup", error);
208262
}
209263
}
210264
};
@@ -213,34 +267,50 @@ export const attachToTextArea = ({
213267
debug("annotations", annotations);
214268
textChecker.updateAnnotations(annotations);
215269
}, lintingDebounceMs);
216-
textAreaElement.addEventListener("compositionstart", compositionHandler);
217-
textAreaElement.addEventListener("compositionend", compositionHandler);
218-
textAreaElement.addEventListener("input", update);
219-
textAreaElement.addEventListener("focusout", dismissCards);
220-
update();
270+
// Events
221271
// when resize element, update annotation
222272
const resizeObserver = new ResizeObserver(() => {
223-
debug("textarea resize");
273+
debug("ResizeObserver do update");
224274
textCheckerPopup.dismissCards();
225275
textChecker.resetAnnotations();
226276
update();
227277
});
228278
resizeObserver.observe(textAreaElement);
229279
// when scroll window, update annotation
230280
const onScroll = () => {
281+
textCheckerPopup.dismissCards();
231282
textChecker.resetAnnotations();
232283
update();
233284
};
285+
const onFocus = () => {
286+
textCheckerPopup.dismissCards();
287+
update();
288+
};
289+
const onBlur = (event: FocusEvent) => {
290+
// does not dismiss on click popup items(require tabindex)
291+
if (event.relatedTarget === textChecker || event.relatedTarget === textCheckerPopup) {
292+
return;
293+
}
294+
textCheckerPopup.dismissCards();
295+
};
296+
textAreaElement.addEventListener("compositionstart", compositionHandler);
297+
textAreaElement.addEventListener("compositionend", compositionHandler);
298+
textAreaElement.addEventListener("input", update);
299+
textAreaElement.addEventListener("focus", onFocus);
300+
textAreaElement.addEventListener("blur", onBlur);
301+
textAreaElement.addEventListener("focusout", dismissCards);
234302
window.addEventListener("scroll", onScroll);
235303
// when scroll the element, update annotation
236304
textAreaElement.addEventListener("scroll", onScroll);
305+
update();
237306
return () => {
238307
window.removeEventListener("scroll", onScroll);
239308
textAreaElement.removeEventListener("scroll", onScroll);
240309
textAreaElement.removeEventListener("compositionstart", compositionHandler);
241310
textAreaElement.removeEventListener("compositionend", compositionHandler);
242311
textAreaElement.removeEventListener("input", update);
243-
textAreaElement.removeEventListener("blur", dismissCards);
312+
textAreaElement.removeEventListener("focus", onFocus);
313+
textAreaElement.removeEventListener("blur", onBlur);
244314
resizeObserver.disconnect();
245315
};
246316
};

packages/textchecker-element/src/text-checker-element.ts

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
import toPX from "to-px";
1111

1212
export type TextCheckerElementAttributes = {
13+
onEnter?: () => void;
14+
onLeave?: () => void;
1315
targetElement: HTMLTextAreaElement;
1416
hoverPadding: number;
1517
};
@@ -31,12 +33,18 @@ export class TextCheckerElement extends HTMLElement {
3133
private store: ReturnType<typeof createTextCheckerStore>;
3234
private hoverPadding: number = 8;
3335
private target!: string;
36+
public isFocus: boolean = false;
37+
public isHovering: boolean = false;
38+
private onEnter: (() => void) | undefined;
39+
private onLeave: (() => void) | undefined;
3440

3541
constructor(args: TextCheckerElementAttributes) {
3642
super();
3743
this.store = createTextCheckerStore();
38-
this.targetElement = args.targetElement;
39-
this.hoverPadding = args.hoverPadding;
44+
this.targetElement = args?.targetElement;
45+
this.onEnter = args?.onEnter;
46+
this.onLeave = args?.onLeave;
47+
this.hoverPadding = args?.hoverPadding ?? 8;
4048
}
4149

4250
static get observedAttributes() {
@@ -74,28 +82,43 @@ export class TextCheckerElement extends HTMLElement {
7482
overlay.append(annotationBox);
7583
shadow.append(overlay);
7684
this.annotationBox = annotationBox;
85+
// we need to capture over textarea
86+
this.targetElement.dataset.attachedTextCheckerElement = "true";
7787
this.targetElement.addEventListener("mousemove", this.onMouseUpdate);
78-
// when scroll the element, update annoation
79-
this.targetElement.addEventListener("scroll", this.updateOnScroll);
88+
this.targetElement.addEventListener("focus", this.onFocus);
89+
this.targetElement.addEventListener("blur", this.onBlur);
90+
this.targetElement.addEventListener("mouseenter", this.onMouseEnter);
91+
this.targetElement.addEventListener("mouseleave", this.onMouseLeave);
8092
this.store.onChange(() => {
8193
this.renderAnnotationMarkers(this.store.get());
8294
});
8395
}
8496

85-
updateOnScroll = () => {};
86-
8797
disconnectedCallback() {
8898
this.targetElement.removeEventListener("mousemove", this.onMouseUpdate);
89-
this.targetElement.removeEventListener("scroll", this.updateOnScroll);
99+
this.targetElement.removeEventListener("focus", this.onFocus);
100+
this.targetElement.removeEventListener("blur", this.onBlur);
101+
this.targetElement.removeEventListener("mouseenter", this.onMouseEnter);
102+
this.targetElement.removeEventListener("mouseleave", this.onMouseLeave);
90103
}
91104

105+
private onMouseEnter = () => {
106+
this.isHovering = true;
107+
this.onEnter?.();
108+
};
109+
110+
private onMouseLeave = () => {
111+
this.isHovering = false;
112+
this.onLeave?.();
113+
this.resetHoverState();
114+
};
115+
92116
resetAnnotations() {
93-
if (this.store.get().rectItems.length === 0) {
94-
return; // no update
95-
}
96-
this.store.update({
97-
rectItems: []
98-
});
117+
this.store.clear();
118+
}
119+
120+
resetHoverState() {
121+
this.store.clearHoverState();
99122
}
100123

101124
updateAnnotations(annotationItems: AnnotationItem[]) {
@@ -259,6 +282,18 @@ export class TextCheckerElement extends HTMLElement {
259282
render(items, this.annotationBox);
260283
};
261284

285+
onFocus = () => {
286+
this.isFocus = true;
287+
};
288+
onBlur = () => {
289+
this.isFocus = false;
290+
};
291+
292+
isHoverRectItem = (rectItem: TextCheckerElementRectItem): boolean => {
293+
const state = this.store.get();
294+
return Boolean(state.mouseHoverRectIdMap.get(rectItem.id));
295+
};
296+
262297
onMouseUpdate = (event: MouseEvent) => {
263298
const state = this.store.get();
264299
const hoverPadding = this.hoverPadding;
@@ -270,32 +305,32 @@ export class TextCheckerElement extends HTMLElement {
270305
};
271306
return (
272307
rect.left - hoverPadding <= point.x &&
273-
point.x <= rect.left + rect.width + hoverPadding &&
308+
rect.left + rect.width + hoverPadding >= point.x &&
274309
rect.top - hoverPadding <= point.y &&
275-
point.y <= rect.top + rect.height + hoverPadding
310+
rect.top + rect.height + hoverPadding >= point.y
276311
);
277312
})
278313
.map((item) => item.id);
279314
// call mouseover
280315
// naive implementation
281316
// TODO: https://github.com/mourner/flatbush is useful for search
282317
state.rectItems.forEach((rectItem) => {
283-
const currentState = state.mouseHoverReactIdMap.get(rectItem.id);
318+
const isHoverRect = state.mouseHoverRectIdMap.get(rectItem.id);
284319
const isIncludedMouse = isIncludedIndexes.includes(rectItem.id);
285-
if (currentState === false && isIncludedMouse) {
320+
if (!isHoverRect && isIncludedMouse) {
286321
state.annotationItems
287322
.find((item) => item.id === rectItem.id)
288323
?.onMouseEnter({
289324
rectItem: rectItem
290325
});
291-
} else if (currentState === true && !isIncludedMouse) {
326+
} else if (isHoverRect && !isIncludedMouse) {
292327
state.annotationItems
293328
.find((item) => item.id === rectItem.id)
294329
?.onMouseLeave({
295330
rectItem: rectItem
296331
});
297332
}
298-
state.mouseHoverReactIdMap.set(rectItem.id, isIncludedMouse);
333+
state.mouseHoverRectIdMap.set(rectItem.id, isIncludedMouse);
299334
});
300335
// update highlight
301336
this.store.highlightRectIndexes(isIncludedIndexes);

0 commit comments

Comments
 (0)