Skip to content

Commit d49dfbc

Browse files
Merge pull request #377 from eccenca/bugfix/emojis-support-CMEM-7217
Fix emoji false-positives in invisible character detection
2 parents 1a1387c + 6267976 commit d49dfbc

5 files changed

Lines changed: 126 additions & 9 deletions

File tree

.typescript/tsbuild-esm.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"extends": "./../tsconfig.json",
33
"compilerOptions": {
4-
"lib": ["dom", "dom.iterable", "es2015", "es2020", "es2021", "es2015.collection", "es2015.iterable"],
4+
"lib": ["dom", "dom.iterable", "es2015", "es2020", "es2021", "es2022.intl", "es2015.collection", "es2015.iterable"],
55
"module": "es2015",
66
"target": "es5",
77
"noEmit": false,

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
4848
- adjust displaying fallback symbols in different browsers
4949
- `<CodeMirror />`
5050
- use the latest provided `onChange` function
51+
- `<TextField />`, `<TextArea />`
52+
- fix emoji false-positives in invisible character detection
5153

5254
### Changed
5355

src/components/TextField/stories/TextField.stories.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,26 @@ const invisibleCharacterWarningProps: TextFieldProps = {
6161
defaultValue: "Invisible character ->​<-",
6262
};
6363
InvisibleCharacterWarning.args = invisibleCharacterWarningProps;
64+
65+
/** Text field showing that emoji (✔️ variation-selector, 👨‍👩‍👧‍👦 ZWJ, #️⃣ keycap)
66+
* are NOT reported as invisible characters, while a genuine ZWS still is. */
67+
export const InvisibleCharacterWarningWithEmoji = Template.bind({});
68+
69+
const invisibleCharacterWarningWithEmojiProps: TextFieldProps = {
70+
...Default.args,
71+
invisibleCharacterWarning: {
72+
callback: (codePoints) => {
73+
if (codePoints.size) {
74+
const codePointsString = [...codePoints]
75+
.map((n) => characters.invisibleZeroWidthCharacters.codePointMap.get(n)?.fullLabel)
76+
.join(", ");
77+
alert("Invisible character detected in input string. Code points: " + codePointsString);
78+
}
79+
},
80+
callbackDelay: 500,
81+
},
82+
onChange: () => {},
83+
// ZWS should be flagged; ✔️ 👨‍👩‍👧‍👦 #️⃣ should NOT be flagged
84+
defaultValue: "Check\u200B ✔️ 👨‍👩‍👧‍👦 #️⃣",
85+
};
86+
InvisibleCharacterWarningWithEmoji.args = invisibleCharacterWarningWithEmojiProps;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React from "react";
2+
import { act, render } from "@testing-library/react";
3+
4+
import { useTextValidation } from "../useTextValidation";
5+
6+
const HookWrapper: React.FC<{ value: string; callback: jest.Mock; callbackDelay?: number }> = ({
7+
value,
8+
callback,
9+
callbackDelay = 0,
10+
}) => {
11+
useTextValidation({
12+
value,
13+
onChange: jest.fn(),
14+
invisibleCharacterWarning: { callback, callbackDelay },
15+
});
16+
return null;
17+
};
18+
19+
describe("useTextValidation", () => {
20+
beforeEach(() => {
21+
jest.useFakeTimers();
22+
});
23+
24+
afterEach(() => {
25+
jest.useRealTimers();
26+
});
27+
28+
/** Render the hook with a controlled value and flush the debounce timer. */
29+
const runWithValue = (value: string, callbackDelay = 0) => {
30+
const callback = jest.fn();
31+
render(<HookWrapper value={value} callback={callback} callbackDelay={callbackDelay} />);
32+
act(() => {
33+
jest.runAllTimers();
34+
});
35+
return callback;
36+
};
37+
38+
describe("invisible character detection", () => {
39+
it("reports empty set for plain text", () => {
40+
const callback = runWithValue("hello world");
41+
expect(callback).toHaveBeenCalledWith(new Set());
42+
});
43+
44+
it("detects zero-width space (U+200B)", () => {
45+
const callback = runWithValue("hello\u200Bworld");
46+
expect(callback).toHaveBeenCalledWith(new Set([0x200b]));
47+
});
48+
49+
it("detects zero-width non-joiner (U+200C)", () => {
50+
const callback = runWithValue("hello\u200Cworld");
51+
expect(callback).toHaveBeenCalledWith(new Set([0x200c]));
52+
});
53+
});
54+
55+
describe("emoji false-positive prevention", () => {
56+
it("does not flag ✔️ (base char + variation selector U+FE0F)", () => {
57+
const callback = runWithValue("✔️");
58+
expect(callback).toHaveBeenCalledWith(new Set());
59+
});
60+
61+
it("does not flag ZWJ sequence emoji 👨‍👩‍👧‍👦", () => {
62+
const callback = runWithValue("👨‍👩‍👧‍👦");
63+
expect(callback).toHaveBeenCalledWith(new Set());
64+
});
65+
66+
it("does not flag keycap emoji #️⃣", () => {
67+
const callback = runWithValue("#️⃣");
68+
expect(callback).toHaveBeenCalledWith(new Set());
69+
});
70+
});
71+
72+
describe("mixed content", () => {
73+
it("detects ZWS while ignoring surrounding emoji", () => {
74+
const callback = runWithValue("Check\u200B ✔️👨‍👩‍👧‍#️⃣");
75+
expect(callback).toHaveBeenCalledWith(new Set([0x200b]));
76+
});
77+
78+
it("reports empty set for text with only emoji", () => {
79+
const callback = runWithValue("✔️ 👨‍👩‍👧‍👦#️⃣");
80+
expect(callback).toHaveBeenCalledWith(new Set());
81+
});
82+
});
83+
});

src/components/TextField/useTextValidation.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,19 +44,28 @@ export const useTextValidation = <T>({ value, onChange, invisibleCharacterWarnin
4444
state.current.detectedCodePoints = new Set();
4545
}, []);
4646
const detectionRegex = React.useMemo(() => chars.invisibleZeroWidthCharacters.createRegex(), []);
47+
const segmenter = React.useMemo(() => new Intl.Segmenter(undefined, { granularity: "grapheme" }), []);
48+
const emojiRegex = React.useMemo(() => new RegExp("\\p{Extended_Pictographic}|\\u20E3", "u"), []);
49+
4750
const detectIssues = React.useCallback(
4851
(value: string): void => {
49-
detectionRegex.lastIndex = 0;
50-
let matchArray = detectionRegex.exec(value);
51-
while (matchArray) {
52-
const codePoint = matchArray[0].codePointAt(0);
53-
if (codePoint) {
54-
state.current.detectedCodePoints.add(codePoint);
52+
for (const { segment } of segmenter.segment(value)) {
53+
if (emojiRegex.test(segment)) {
54+
// skip emoji clusters since they legitimately contain variation selectors, ZWJ, tags, etc.
55+
} else {
56+
detectionRegex.lastIndex = 0;
57+
let matchArray = detectionRegex.exec(segment);
58+
while (matchArray) {
59+
const codePoint = matchArray[0].codePointAt(0);
60+
if (codePoint) {
61+
state.current.detectedCodePoints.add(codePoint);
62+
}
63+
matchArray = detectionRegex.exec(segment);
64+
}
5565
}
56-
matchArray = detectionRegex.exec(value);
5766
}
5867
},
59-
[detectionRegex]
68+
[detectionRegex, segmenter, emojiRegex]
6069
);
6170
// Checks if the value contains any problematic characters with a small delay.
6271
const checkValue = React.useCallback(

0 commit comments

Comments
 (0)