Skip to content

Commit

Permalink
fix: improve stability (#44)
Browse files Browse the repository at this point in the history
  • Loading branch information
azu authored Apr 29, 2021
1 parent 3ee2a87 commit 270b400
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 69 deletions.
154 changes: 112 additions & 42 deletions packages/textchecker-element/src/attach-to-text-area.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { TextCheckerCard, TextCheckerPopupElement, TextCheckerPopupElementArgs }
import type { TextlintMessage, TextlintResult } from "@textlint/types";
import type { TextCheckerElementRectItem } from "./text-checker-store";
import pDebounce from "p-debounce";
import delay from "delay";
import { debug } from "./util/logger";

const createCompositionHandler = () => {
Expand Down Expand Up @@ -48,15 +47,58 @@ export type AttachTextAreaParams = {
lintEngine: LintEngineAPI;
};

let textCheckerPopup: TextCheckerPopupElement;
const createTextCheckerPopupElement = (args: TextCheckerPopupElementArgs) => {
if (textCheckerPopup) {
return textCheckerPopup;
}
textCheckerPopup = new TextCheckerPopupElement(args);
const textCheckerPopup = new TextCheckerPopupElement(args);
document.body.append(textCheckerPopup);
return textCheckerPopup;
};

/**
* Return true if the element in viewport
* @param element
*/
function isVisibleInViewport(element: HTMLElement): boolean {
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
const rect = element.getBoundingClientRect();
if (rect.height === 0 || rect.width === 0) {
return false;
}
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}

/**
* Dismiss all popup
*
* - Update text(includes fixed)
* - Scroll textarea/Scroll window
* - Focus on textarea
* - Click out of textarea/popup
* - popup → textarea → other → dismiss
* - textarea → popup → other → dismiss
*/

/**
* Dismiss a single popup(500ms delay)
*
* - Leave from popup
* - Leave from RectItem
* - Focus on textarea
*
*/

/**
* Show popup condition
* - onUpdate
*/

/**
* Attach text-checker component to `<textarea>` element
*/
Expand All @@ -73,32 +115,45 @@ export const attachToTextArea = ({
debug("Can not attach textarea that is readonly", textAreaElement);
return () => {};
}
const textChecker = new TextCheckerElement({
targetElement: textAreaElement,
hoverPadding: 20
});
textAreaElement.before(textChecker);
const hoverMap = new Map<TextCheckerElementRectItem, boolean>();
if (textAreaElement.dataset.attachedTextCheckerElement === "true") {
debug("Can not attach textarea that is already attached", textAreaElement);
return () => {};
}
const dismissCards = () => {
if (!textCheckerPopup.isHovering && hoverMap.size === 0) {
debug("dismissCards", {
textCheckerPopup: textCheckerPopup.isHovering,
textChecker: textChecker.isHovering,
textCheckerF: textChecker.isFocus
});
if (!textCheckerPopup.isHovering && !textChecker.isHovering && !textChecker.isFocus) {
textCheckerPopup.dismissCards();
textChecker.resetHoverState();
}
};
const textCheckerPopup = createTextCheckerPopupElement({
onLeave() {
if (!textCheckerPopup.isHovering && hoverMap.size === 0) {
textCheckerPopup.dismissCards();
}
dismissCards();
}
});
const textChecker = new TextCheckerElement({
targetElement: textAreaElement,
hoverPadding: 20,
onLeave() {
dismissCards();
}
});
textAreaElement.before(textChecker);
const compositionHandler = createCompositionHandler();
const update = pDebounce(async () => {
if (!isVisibleInViewport(textAreaElement)) {
return;
}
// stop lint on IME composition
if (compositionHandler.onComposition) {
return;
}
// dismiss card before update annotations
textCheckerPopup.dismissCards();
// dismissCards();
const text = textAreaElement.value;
const results = await lintEngine.lintText({
text
Expand All @@ -120,19 +175,16 @@ export const attachToTextArea = ({
messageRuleId: message.ruleId,
fixable: Boolean(message.fix)
};
const abortSignalMap = new WeakMap<TextCheckerElementRectItem, AbortController>();
let dismissTimerId: null | any = null;
return {
id: `${message.ruleId}::${message.line}:${message.column}`,
start: message.index,
end: message.index + 1,
onMouseEnter: ({ rectItem }: { rectItem: TextCheckerElementRectItem }) => {
hoverMap.set(rectItem, true);
const controller = abortSignalMap.get(rectItem);
debug("enter", controller);
if (controller) {
controller.abort();
debug("annotation - onMouseEnter");
if (dismissTimerId) {
clearTimeout(dismissTimerId);
}
abortSignalMap.set(rectItem, new AbortController());
textCheckerPopup.updateCard({
card: card,
rect: {
Expand Down Expand Up @@ -193,18 +245,20 @@ export const attachToTextArea = ({
},
async onMouseLeave({ rectItem }: { rectItem: TextCheckerElementRectItem }) {
try {
hoverMap.delete(rectItem);
const controller = abortSignalMap.get(rectItem);
debug("leave", controller);
await delay(500, {
signal: controller?.signal
});
if (textCheckerPopup.isHovering || hoverMap.get(rectItem)) {
return;
}
textCheckerPopup.dismissCard(card);
debug("annotation - onMouseLeave");
dismissTimerId = setTimeout(() => {
const isHover = textChecker.isHoverRectItem(rectItem);
debug("dismiss", {
textCheckerPopup: textCheckerPopup.isHovering,
isRectElementHover: isHover
});
if (textCheckerPopup.isHovering || isHover) {
return;
}
textCheckerPopup.dismissCard(card);
}, 500);
} catch (error) {
debug("Abort Canceled", error);
debug("Abort dismiss popup", error);
}
}
};
Expand All @@ -213,34 +267,50 @@ export const attachToTextArea = ({
debug("annotations", annotations);
textChecker.updateAnnotations(annotations);
}, lintingDebounceMs);
textAreaElement.addEventListener("compositionstart", compositionHandler);
textAreaElement.addEventListener("compositionend", compositionHandler);
textAreaElement.addEventListener("input", update);
textAreaElement.addEventListener("focusout", dismissCards);
update();
// Events
// when resize element, update annotation
const resizeObserver = new ResizeObserver(() => {
debug("textarea resize");
debug("ResizeObserver do update");
textCheckerPopup.dismissCards();
textChecker.resetAnnotations();
update();
});
resizeObserver.observe(textAreaElement);
// when scroll window, update annotation
const onScroll = () => {
textCheckerPopup.dismissCards();
textChecker.resetAnnotations();
update();
};
const onFocus = () => {
textCheckerPopup.dismissCards();
update();
};
const onBlur = (event: FocusEvent) => {
// does not dismiss on click popup items(require tabindex)
if (event.relatedTarget === textChecker || event.relatedTarget === textCheckerPopup) {
return;
}
textCheckerPopup.dismissCards();
};
textAreaElement.addEventListener("compositionstart", compositionHandler);
textAreaElement.addEventListener("compositionend", compositionHandler);
textAreaElement.addEventListener("input", update);
textAreaElement.addEventListener("focus", onFocus);
textAreaElement.addEventListener("blur", onBlur);
textAreaElement.addEventListener("focusout", dismissCards);
window.addEventListener("scroll", onScroll);
// when scroll the element, update annotation
textAreaElement.addEventListener("scroll", onScroll);
update();
return () => {
window.removeEventListener("scroll", onScroll);
textAreaElement.removeEventListener("scroll", onScroll);
textAreaElement.removeEventListener("compositionstart", compositionHandler);
textAreaElement.removeEventListener("compositionend", compositionHandler);
textAreaElement.removeEventListener("input", update);
textAreaElement.removeEventListener("blur", dismissCards);
textAreaElement.removeEventListener("focus", onFocus);
textAreaElement.removeEventListener("blur", onBlur);
resizeObserver.disconnect();
};
};
73 changes: 54 additions & 19 deletions packages/textchecker-element/src/text-checker-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
import toPX from "to-px";

export type TextCheckerElementAttributes = {
onEnter?: () => void;
onLeave?: () => void;
targetElement: HTMLTextAreaElement;
hoverPadding: number;
};
Expand All @@ -31,12 +33,18 @@ export class TextCheckerElement extends HTMLElement {
private store: ReturnType<typeof createTextCheckerStore>;
private hoverPadding: number = 8;
private target!: string;
public isFocus: boolean = false;
public isHovering: boolean = false;
private onEnter: (() => void) | undefined;
private onLeave: (() => void) | undefined;

constructor(args: TextCheckerElementAttributes) {
super();
this.store = createTextCheckerStore();
this.targetElement = args.targetElement;
this.hoverPadding = args.hoverPadding;
this.targetElement = args?.targetElement;
this.onEnter = args?.onEnter;
this.onLeave = args?.onLeave;
this.hoverPadding = args?.hoverPadding ?? 8;
}

static get observedAttributes() {
Expand Down Expand Up @@ -74,28 +82,43 @@ export class TextCheckerElement extends HTMLElement {
overlay.append(annotationBox);
shadow.append(overlay);
this.annotationBox = annotationBox;
// we need to capture over textarea
this.targetElement.dataset.attachedTextCheckerElement = "true";
this.targetElement.addEventListener("mousemove", this.onMouseUpdate);
// when scroll the element, update annoation
this.targetElement.addEventListener("scroll", this.updateOnScroll);
this.targetElement.addEventListener("focus", this.onFocus);
this.targetElement.addEventListener("blur", this.onBlur);
this.targetElement.addEventListener("mouseenter", this.onMouseEnter);
this.targetElement.addEventListener("mouseleave", this.onMouseLeave);
this.store.onChange(() => {
this.renderAnnotationMarkers(this.store.get());
});
}

updateOnScroll = () => {};

disconnectedCallback() {
this.targetElement.removeEventListener("mousemove", this.onMouseUpdate);
this.targetElement.removeEventListener("scroll", this.updateOnScroll);
this.targetElement.removeEventListener("focus", this.onFocus);
this.targetElement.removeEventListener("blur", this.onBlur);
this.targetElement.removeEventListener("mouseenter", this.onMouseEnter);
this.targetElement.removeEventListener("mouseleave", this.onMouseLeave);
}

private onMouseEnter = () => {
this.isHovering = true;
this.onEnter?.();
};

private onMouseLeave = () => {
this.isHovering = false;
this.onLeave?.();
this.resetHoverState();
};

resetAnnotations() {
if (this.store.get().rectItems.length === 0) {
return; // no update
}
this.store.update({
rectItems: []
});
this.store.clear();
}

resetHoverState() {
this.store.clearHoverState();
}

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

onFocus = () => {
this.isFocus = true;
};
onBlur = () => {
this.isFocus = false;
};

isHoverRectItem = (rectItem: TextCheckerElementRectItem): boolean => {
const state = this.store.get();
return Boolean(state.mouseHoverRectIdMap.get(rectItem.id));
};

onMouseUpdate = (event: MouseEvent) => {
const state = this.store.get();
const hoverPadding = this.hoverPadding;
Expand All @@ -270,32 +305,32 @@ export class TextCheckerElement extends HTMLElement {
};
return (
rect.left - hoverPadding <= point.x &&
point.x <= rect.left + rect.width + hoverPadding &&
rect.left + rect.width + hoverPadding >= point.x &&
rect.top - hoverPadding <= point.y &&
point.y <= rect.top + rect.height + hoverPadding
rect.top + rect.height + hoverPadding >= point.y
);
})
.map((item) => item.id);
// call mouseover
// naive implementation
// TODO: https://github.com/mourner/flatbush is useful for search
state.rectItems.forEach((rectItem) => {
const currentState = state.mouseHoverReactIdMap.get(rectItem.id);
const isHoverRect = state.mouseHoverRectIdMap.get(rectItem.id);
const isIncludedMouse = isIncludedIndexes.includes(rectItem.id);
if (currentState === false && isIncludedMouse) {
if (!isHoverRect && isIncludedMouse) {
state.annotationItems
.find((item) => item.id === rectItem.id)
?.onMouseEnter({
rectItem: rectItem
});
} else if (currentState === true && !isIncludedMouse) {
} else if (isHoverRect && !isIncludedMouse) {
state.annotationItems
.find((item) => item.id === rectItem.id)
?.onMouseLeave({
rectItem: rectItem
});
}
state.mouseHoverReactIdMap.set(rectItem.id, isIncludedMouse);
state.mouseHoverRectIdMap.set(rectItem.id, isIncludedMouse);
});
// update highlight
this.store.highlightRectIndexes(isIncludedIndexes);
Expand Down
Loading

0 comments on commit 270b400

Please sign in to comment.