Repository: microsoft/vscode (TypeScript)
Date: 2021-05-05 09:22:05+00:00
Task type: Bug Fix

Issue:
Bug: Emoji modifiers in text treated separately when using backspace
<!-- ⚠️⚠️ Do Not Delete This! bug_report_template ⚠️⚠️ -->
<!-- Please read our Rules of Conduct: https://opensource.microsoft.com/codeofconduct/ -->
<!-- Please search existing issues to avoid creating duplicates. -->
<!-- Also please test using the latest insiders build to make sure your issue has not already been fixed: https://code.visualstudio.com/insiders/ -->

<!-- Use Help > Report Issue to prefill these. -->
- VSCode Version: All recent versions, maybe all versions, including Monaco outside of VSCode
- OS Version: macOS, Windows, likely Linux and all others

Steps to Reproduce:

1. Enter an Emoji with a modifier, such as a Fitzpatrick skin type modifier: 👶🏾
2. Hit backspace after that character
3. According to the Unicode specification the entire character should be removed.
4. VSCode removes the modifier and leaves the Emoji, producing 👶

The relevant spec, [UTS #51](https://www.unicode.org/reports/tr51/#valid-emoji-tag-sequences), states that:

> A supported emoji modifier sequence should be treated as a single grapheme cluster for editing purposes (cursor moment, deletion, and so on); word break, line break, and so on.

This is probably related to #19390, directly to [a comment about ⚠️](https://github.com/microsoft/vscode/issues/19390#issuecomment-399767433), but separate enough that I suppose it warrants its own issue.

It's worth noting that aspects of this work as expected. The editor won't let you select he modifier - it correctly forces you to select the modifier plus the Emoji it modifies as an atomic whole. As reported in #19390 though it's possible to move the cursor inside the modified grapheme cluster and break it. (While the cursor bug doesn't appear in the Monaco playground the backspace bug does).

<!-- Launch with `code --disable-extensions` to check. -->
Does this issue occur when all extensions are disabled?: Yes



Issue discussion:
Since about 6 months, we have implemented Unicode's grapheme break rules. So it is not possible to place the cursor inside emojis. A Delete (Fn+Backspace) before the emoji will remove the entire grapheme. But Backspace will only delete one code point at a time. We have made the decision to behave like this due to it being used for input building in various languages. See example in #84897.

See also #69900

There's also an interesting blog post at https://lord.io/blog/2019/text-editing-hates-you-too/
> We have made the decision to behave like this due to it being used for input building in various languages.

Thanks for explaining @alexdima - I guess that means there's a disagreement with the Unicode spec? Needing to hit backspace seven times to delete a single flag, for example, is something the spec was trying to prevent.

The example in #84897 seems like a different issue, especially since the spec calls out Emoji, Emoji with modifiers, and Emoji Tag Sequences as specific and separate things.

I guess I'm a little confused if you are more or less explaining the context behind why this bug exists or saying that the VSCode team doesn't plan on fixing it or that y'all disagree that it's a bug.

In any case thanks for your help. I know text editing is hard - that's why I've been excited to try out Monaco because it seems like it does a better job than most web-based components.
@dmsnell There's no disagreement with the Unicode spec. In our code, we currently lack the ability to distinguish the emoji flag case from the Thai characters case. Since this use case is important for typing in Thai, we have chosen to allow backspace to remove the last code point from a grapheme, regardless of which grapheme that is. We would need to further refine this and make backspace delete the entire grapheme in case the grapheme is an emoji.
@alexdima is there a way we can reclassify this as a bug report vs. a feature request since it's actually reporting a failure to handle Unicode text properly?

It's totally understandable that fixing this could involve a lot of work and only cure relatively rare cases of text; but those are still legitimate cases. Maybe it's just me but I'd feel much better if it were closed at `wont-fix` rather than a feature request that didn't garner enough support.

That is, okay we close this bug report and call it a feature request but unless Monaco starts distinguishing Emoji characters from others it will continue to be a bug in Monaco whether it's on the roadmap or not. `wont-fix` seems more honest or less dismissive.

Thanks again for interacting here. We've continued to incorporate more of the Monaco APIs into Simplenote and while some things are still a bit unsteady it's been a nice improvement for the most basic text editing functionality.
Another use-case is https://github.com/microsoft/vscode/issues/109812 for ✔
Interestingly, even browsers show different behaviors when hitting backspace after Emojis with modifiers or combinations of those separated by the Zero Width Joiner (ZWJ).

Firefox 88:
* Address-bar: "✔️" -> ""
* Address-bar: "👨‍👨‍👧‍👧" -> "👨‍👨‍👧" -> "👨‍👨" -> "👨" -> ""
* Input/Text-Areas behave the same

Chrome 90:
* Address-bar: "✔️" -> "✔" -> ""
* Input/Text-Area: "✔️" -> ""
* Address-bar: "👨‍👨‍👧‍👧" -> "👨‍👨‍👧‍" -> "👨‍👨‍👧" -> "👨‍👨‍" -> "👨‍👨" -> "👨‍" -> "👨" -> ""
* Input/Text-Area: "👨‍👨‍👧‍👧" -> ""

Monacos behavior seems to be consistent with Chromes address-bar.
I think Firefox got it right: It always removes the modifier with the modified emoji, but deletes ZWJ-combined emojis step by step.
You can even combine modified Emojis (👨‍👩🏽‍👧‍👦)!

VS Codes html input fields behave as Chromes input fields. Thus, combined emojis are deleted at once. What should be implemented for Monaco?

Solution patch:
diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts
--- a/src/vs/base/common/strings.ts
+++ b/src/vs/base/common/strings.ts
@@ -1082,3 +1082,81 @@ function getGraphemeBreakRawData(): number[] {
 }
 
 //#endregion
+
+/**
+ * Computes the offset after performing a left delete on the given string,
+ * while considering unicode grapheme/emoji rules.
+*/
+export function getLeftDeleteOffset(offset: number, str: string): number {
+	if (offset === 0) {
+		return 0;
+	}
+
+	// Try to delete emoji part.
+	const emojiOffset = getOffsetBeforeLastEmojiComponent(offset, str);
+	if (emojiOffset !== undefined) {
+		return emojiOffset;
+	}
+
+	// Otherwise, just skip a single code point.
+	const codePoint = getPrevCodePoint(str, offset);
+	offset -= getUTF16Length(codePoint);
+	return offset;
+}
+
+function getOffsetBeforeLastEmojiComponent(offset: number, str: string): number | undefined {
+	// See https://www.unicode.org/reports/tr51/tr51-14.html#EBNF_and_Regex for the
+	// structure of emojis.
+	let codePoint = getPrevCodePoint(str, offset);
+	offset -= getUTF16Length(codePoint);
+
+	// Skip modifiers
+	while ((isEmojiModifier(codePoint) || codePoint === CodePoint.emojiVariantSelector || codePoint === CodePoint.enclosingKeyCap)) {
+		if (offset === 0) {
+			// Cannot skip modifier, no preceding emoji base.
+			return undefined;
+		}
+		codePoint = getPrevCodePoint(str, offset);
+		offset -= getUTF16Length(codePoint);
+	}
+
+	// Expect base emoji
+	if (!isEmojiImprecise(codePoint)) {
+		// Unexpected code point, not a valid emoji.
+		return undefined;
+	}
+
+	if (offset >= 0) {
+		// Skip optional ZWJ code points that combine multiple emojis.
+		// In theory, we should check if that ZWJ actually combines multiple emojis
+		// to prevent deleting ZWJs in situations we didn't account for.
+		const optionalZwjCodePoint = getPrevCodePoint(str, offset);
+		if (optionalZwjCodePoint === CodePoint.zwj) {
+			offset -= getUTF16Length(optionalZwjCodePoint);
+		}
+	}
+
+	return offset;
+}
+
+function getUTF16Length(codePoint: number) {
+	return codePoint >= Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN ? 2 : 1;
+}
+
+function isEmojiModifier(codePoint: number): boolean {
+	return 0x1F3FB <= codePoint && codePoint <= 0x1F3FF;
+}
+
+const enum CodePoint {
+	zwj = 0x200D,
+
+	/**
+	 * Variation Selector-16 (VS16)
+	*/
+	emojiVariantSelector = 0xFE0F,
+
+	/**
+	 * Combining Enclosing Keycap
+	 */
+	enclosingKeyCap = 0x20E3,
+}
diff --git a/src/vs/editor/common/controller/cursorDeleteOperations.ts b/src/vs/editor/common/controller/cursorDeleteOperations.ts
--- a/src/vs/editor/common/controller/cursorDeleteOperations.ts
+++ b/src/vs/editor/common/controller/cursorDeleteOperations.ts
@@ -194,17 +194,19 @@ export class DeleteOperations {
 			}
 		}
 
-		return Range.fromPositions(DeleteOperations.decreasePositionInModelBy1Column(position, model) || position, position);
+		return Range.fromPositions(DeleteOperations.getPositionAfterDeleteLeft(position, model), position);
 	}
 
-	private static decreasePositionInModelBy1Column(position: Position, model: ICursorSimpleModel): Position | undefined {
+	private static getPositionAfterDeleteLeft(position: Position, model: ICursorSimpleModel): Position {
 		if (position.column > 1) {
-			return position.delta(0, -1);
+			// Convert 1-based columns to 0-based offsets and back.
+			const idx = strings.getLeftDeleteOffset(position.column - 1, model.getLineContent(position.lineNumber));
+			return position.with(undefined, idx + 1);
 		} else if (position.lineNumber > 1) {
 			const newLine = position.lineNumber - 1;
 			return new Position(newLine, model.getLineMaxColumn(newLine));
 		} else {
-			return undefined;
+			return position;
 		}
 	}
 


Test patch:
diff --git a/src/vs/editor/test/browser/controller/cursor.test.ts b/src/vs/editor/test/browser/controller/cursor.test.ts
--- a/src/vs/editor/test/browser/controller/cursor.test.ts
+++ b/src/vs/editor/test/browser/controller/cursor.test.ts
@@ -2642,6 +2642,55 @@ suite('Editor Controller - Regression tests', () => {
 
 		model.dispose();
 	});
+
+	test('issue #99629: Emoji modifiers in text treated separately when using backspace', () => {
+		const model = createTextModel(
+			[
+				'👶🏾'
+			].join('\n')
+		);
+
+		withTestCodeEditor(null, { model: model, useTabStops: false }, (editor, viewModel) => {
+			const len = model.getValueLength();
+			editor.setSelections([
+				new Selection(1, 1 + len, 1, 1 + len)
+			]);
+
+			CoreEditingCommands.DeleteLeft.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), '');
+		});
+
+		model.dispose();
+	});
+
+	test('issue #99629: Emoji modifiers in text treated separately when using backspace (ZWJ sequence)', () => {
+		let model = createTextModel(
+			[
+				'👨‍👩🏽‍👧‍👦'
+			].join('\n')
+		);
+
+		withTestCodeEditor(null, { model: model, useTabStops: false }, (editor, viewModel) => {
+			const len = model.getValueLength();
+			editor.setSelections([
+				new Selection(1, 1 + len, 1, 1 + len)
+			]);
+
+			CoreEditingCommands.DeleteLeft.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), '👨‍👩🏽‍👧');
+
+			CoreEditingCommands.DeleteLeft.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), '👨‍👩🏽');
+
+			CoreEditingCommands.DeleteLeft.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), '👨');
+
+			CoreEditingCommands.DeleteLeft.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), '');
+		});
+
+		model.dispose();
+	});
 });
 
 suite('Editor Controller - Cursor Configuration', () => {