Rich Text Editor: Add emoji suggestion support (#30873)
* Add support for emoji suggestions To both the rich text/plain text modes of the RTE. * Add emoji completion test to WysiwygComposer * Fix code as per test case, do no-op for community case * bump wysiwyg to the version with suggestions supported. * Add more unit tests for processTextReplacement
This commit is contained in:
+1
-1
@@ -98,7 +98,7 @@
|
|||||||
"@types/png-chunks-extract": "^1.0.2",
|
"@types/png-chunks-extract": "^1.0.2",
|
||||||
"@vector-im/compound-design-tokens": "^6.0.0",
|
"@vector-im/compound-design-tokens": "^6.0.0",
|
||||||
"@vector-im/compound-web": "^8.1.2",
|
"@vector-im/compound-web": "^8.1.2",
|
||||||
"@vector-im/matrix-wysiwyg": "2.39.0",
|
"@vector-im/matrix-wysiwyg": "2.40.0",
|
||||||
"@zxcvbn-ts/core": "^3.0.4",
|
"@zxcvbn-ts/core": "^3.0.4",
|
||||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||||
"@zxcvbn-ts/language-en": "^3.0.2",
|
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function PlainTextComposer({
|
|||||||
handleCommand,
|
handleCommand,
|
||||||
handleMention,
|
handleMention,
|
||||||
handleAtRoomMention,
|
handleAtRoomMention,
|
||||||
|
handleEmoji,
|
||||||
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled);
|
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled);
|
||||||
const composerFunctions = useComposerFunctions(editorRef, setContent);
|
const composerFunctions = useComposerFunctions(editorRef, setContent);
|
||||||
usePlainTextInitialization(initialContent, editorRef);
|
usePlainTextInitialization(initialContent, editorRef);
|
||||||
@@ -84,6 +85,7 @@ export function PlainTextComposer({
|
|||||||
handleMention={handleMention}
|
handleMention={handleMention}
|
||||||
handleCommand={handleCommand}
|
handleCommand={handleCommand}
|
||||||
handleAtRoomMention={handleAtRoomMention}
|
handleAtRoomMention={handleAtRoomMention}
|
||||||
|
handleEmoji={handleEmoji}
|
||||||
/>
|
/>
|
||||||
<Editor
|
<Editor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ interface WysiwygAutocompleteProps {
|
|||||||
*/
|
*/
|
||||||
handleAtRoomMention: FormattingFunctions["mentionAtRoom"];
|
handleAtRoomMention: FormattingFunctions["mentionAtRoom"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This handler will be called with the emoji character on clicking
|
||||||
|
* an emoji in the autocomplete list or pressing enter on a selected item
|
||||||
|
*/
|
||||||
|
handleEmoji: FormattingFunctions["emoji"];
|
||||||
|
|
||||||
ref?: Ref<Autocomplete>;
|
ref?: Ref<Autocomplete>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +61,7 @@ const WysiwygAutocomplete = ({
|
|||||||
handleMention,
|
handleMention,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
handleAtRoomMention,
|
handleAtRoomMention,
|
||||||
|
handleEmoji,
|
||||||
ref,
|
ref,
|
||||||
}: WysiwygAutocompleteProps): JSX.Element | null => {
|
}: WysiwygAutocompleteProps): JSX.Element | null => {
|
||||||
const { room } = useScopedRoomContext("room");
|
const { room } = useScopedRoomContext("room");
|
||||||
@@ -89,7 +96,14 @@ const WysiwygAutocomplete = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// TODO - handle "community" type
|
// TODO - handle "community" type
|
||||||
|
case "community": {
|
||||||
|
return; // no-op until we decide how to handle community in the wysiwyg composer
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
|
{
|
||||||
|
// similar to the cider editor we handle emoji and other plain text replacement in the default case
|
||||||
|
handleEmoji(completion.completion);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
|
|||||||
handleMention={wysiwyg.mention}
|
handleMention={wysiwyg.mention}
|
||||||
handleAtRoomMention={wysiwyg.mentionAtRoom}
|
handleAtRoomMention={wysiwyg.mentionAtRoom}
|
||||||
handleCommand={wysiwyg.command}
|
handleCommand={wysiwyg.command}
|
||||||
|
handleEmoji={wysiwyg.emoji}
|
||||||
/>
|
/>
|
||||||
<FormattingButtons composer={wysiwyg} actionStates={actionStates} disabled={disableFormatting} />
|
<FormattingButtons composer={wysiwyg} actionStates={actionStates} disabled={disableFormatting} />
|
||||||
<Editor
|
<Editor
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function usePlainTextListeners(
|
|||||||
handleMention: (link: string, text: string, attributes: AllowedMentionAttributes) => void;
|
handleMention: (link: string, text: string, attributes: AllowedMentionAttributes) => void;
|
||||||
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
|
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
|
||||||
handleCommand: (text: string) => void;
|
handleCommand: (text: string) => void;
|
||||||
|
handleEmoji: (emoji: string) => void;
|
||||||
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
|
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
|
||||||
suggestion: MappedSuggestion | null;
|
suggestion: MappedSuggestion | null;
|
||||||
} {
|
} {
|
||||||
@@ -95,8 +96,15 @@ export function usePlainTextListeners(
|
|||||||
// For separation of concerns, the suggestion handling is kept in a separate hook but is
|
// For separation of concerns, the suggestion handling is kept in a separate hook but is
|
||||||
// nested here because we do need to be able to update the `content` state in this hook
|
// nested here because we do need to be able to update the `content` state in this hook
|
||||||
// when a user selects a suggestion from the autocomplete menu
|
// when a user selects a suggestion from the autocomplete menu
|
||||||
const { suggestion, onSelect, handleCommand, handleMention, handleAtRoomMention, handleEmojiReplacement } =
|
const {
|
||||||
useSuggestion(ref, setText, isAutoReplaceEmojiEnabled);
|
suggestion,
|
||||||
|
onSelect,
|
||||||
|
handleCommand,
|
||||||
|
handleMention,
|
||||||
|
handleAtRoomMention,
|
||||||
|
handleEmojiSuggestion,
|
||||||
|
handleEmojiReplacement,
|
||||||
|
} = useSuggestion(ref, setText, isAutoReplaceEmojiEnabled);
|
||||||
|
|
||||||
const onInput = useCallback(
|
const onInput = useCallback(
|
||||||
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
|
(event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
|
||||||
@@ -178,5 +186,6 @@ export function usePlainTextListeners(
|
|||||||
handleCommand,
|
handleCommand,
|
||||||
handleMention,
|
handleMention,
|
||||||
handleAtRoomMention,
|
handleAtRoomMention,
|
||||||
|
handleEmoji: handleEmojiSuggestion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function useSuggestion(
|
|||||||
handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void;
|
handleMention: (href: string, displayName: string, attributes: AllowedMentionAttributes) => void;
|
||||||
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
|
handleAtRoomMention: (attributes: AllowedMentionAttributes) => void;
|
||||||
handleCommand: (text: string) => void;
|
handleCommand: (text: string) => void;
|
||||||
|
handleEmojiSuggestion: (text: string) => void;
|
||||||
handleEmojiReplacement: () => void;
|
handleEmojiReplacement: () => void;
|
||||||
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
|
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
|
||||||
suggestion: MappedSuggestion | null;
|
suggestion: MappedSuggestion | null;
|
||||||
@@ -86,11 +87,15 @@ export function useSuggestion(
|
|||||||
|
|
||||||
const handleEmojiReplacement = (): void => processEmojiReplacement(suggestionData, setSuggestionData, setText);
|
const handleEmojiReplacement = (): void => processEmojiReplacement(suggestionData, setSuggestionData, setText);
|
||||||
|
|
||||||
|
const handleEmojiSuggestion = (emoji: string): void =>
|
||||||
|
processTextReplacement(emoji, suggestionData, setSuggestionData, setText);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
suggestion: suggestionData?.mappedSuggestion ?? null,
|
suggestion: suggestionData?.mappedSuggestion ?? null,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
handleMention,
|
handleMention,
|
||||||
handleAtRoomMention,
|
handleAtRoomMention,
|
||||||
|
handleEmojiSuggestion,
|
||||||
handleEmojiReplacement,
|
handleEmojiReplacement,
|
||||||
onSelect,
|
onSelect,
|
||||||
};
|
};
|
||||||
@@ -260,10 +265,31 @@ export function processEmojiReplacement(
|
|||||||
setText: (text?: string) => void,
|
setText: (text?: string) => void,
|
||||||
): void {
|
): void {
|
||||||
// if we do not have a suggestion of the correct type, return early
|
// if we do not have a suggestion of the correct type, return early
|
||||||
if (suggestionData === null || suggestionData.mappedSuggestion.type !== `custom`) {
|
if (suggestionData?.mappedSuggestion?.type !== `custom`) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { node, mappedSuggestion } = suggestionData;
|
|
||||||
|
processTextReplacement(suggestionData.mappedSuggestion.text, suggestionData, setSuggestionData, setText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the relevant part of the editor text, replacing the suggestionData selection with the replacement text.
|
||||||
|
* @param replacementText - the text that we will insert into the DOM
|
||||||
|
* @param suggestionData - representation of the part of the DOM that will be replaced
|
||||||
|
* @param setSuggestionData - setter function to set the suggestion state
|
||||||
|
* @param setText - setter function to set the content of the composer
|
||||||
|
*/
|
||||||
|
export function processTextReplacement(
|
||||||
|
replacementText: string,
|
||||||
|
suggestionData: SuggestionState,
|
||||||
|
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
|
||||||
|
setText: (text?: string) => void,
|
||||||
|
): void {
|
||||||
|
// if we do not have suggestion data return early
|
||||||
|
if (suggestionData === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { node } = suggestionData;
|
||||||
const existingContent = node.textContent;
|
const existingContent = node.textContent;
|
||||||
|
|
||||||
if (existingContent == null) {
|
if (existingContent == null) {
|
||||||
@@ -273,7 +299,7 @@ export function processEmojiReplacement(
|
|||||||
// replace the emoticon with the suggesed emoji
|
// replace the emoticon with the suggesed emoji
|
||||||
const newContent =
|
const newContent =
|
||||||
existingContent.slice(0, suggestionData.startOffset) +
|
existingContent.slice(0, suggestionData.startOffset) +
|
||||||
mappedSuggestion.text +
|
replacementText +
|
||||||
existingContent.slice(suggestionData.endOffset);
|
existingContent.slice(suggestionData.endOffset);
|
||||||
|
|
||||||
node.textContent = newContent;
|
node.textContent = newContent;
|
||||||
@@ -405,6 +431,8 @@ export function getMappedSuggestion(text: string, isAutoReplaceEmojiEnabled?: bo
|
|||||||
case "#":
|
case "#":
|
||||||
case "@":
|
case "@":
|
||||||
return { keyChar: firstChar, text: restOfString, type: "mention" };
|
return { keyChar: firstChar, text: restOfString, type: "mention" };
|
||||||
|
case ":":
|
||||||
|
return { keyChar: firstChar, text: restOfString, type: "emoji" };
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -66,6 +66,7 @@ describe("WysiwygAutocomplete", () => {
|
|||||||
const mockHandleMention = jest.fn();
|
const mockHandleMention = jest.fn();
|
||||||
const mockHandleCommand = jest.fn();
|
const mockHandleCommand = jest.fn();
|
||||||
const mockHandleAtRoomMention = jest.fn();
|
const mockHandleAtRoomMention = jest.fn();
|
||||||
|
const mockHandleEmoji = jest.fn();
|
||||||
|
|
||||||
const renderComponent = (props: Partial<React.ComponentProps<typeof WysiwygAutocomplete>> = {}) => {
|
const renderComponent = (props: Partial<React.ComponentProps<typeof WysiwygAutocomplete>> = {}) => {
|
||||||
const mockClient = stubClient();
|
const mockClient = stubClient();
|
||||||
@@ -81,6 +82,7 @@ describe("WysiwygAutocomplete", () => {
|
|||||||
handleMention={mockHandleMention}
|
handleMention={mockHandleMention}
|
||||||
handleCommand={mockHandleCommand}
|
handleCommand={mockHandleCommand}
|
||||||
handleAtRoomMention={mockHandleAtRoomMention}
|
handleAtRoomMention={mockHandleAtRoomMention}
|
||||||
|
handleEmoji={mockHandleEmoji}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
@@ -96,6 +98,7 @@ describe("WysiwygAutocomplete", () => {
|
|||||||
handleMention={mockHandleMention}
|
handleMention={mockHandleMention}
|
||||||
handleCommand={mockHandleCommand}
|
handleCommand={mockHandleCommand}
|
||||||
handleAtRoomMention={mockHandleAtRoomMention}
|
handleAtRoomMention={mockHandleAtRoomMention}
|
||||||
|
handleEmoji={mockHandleEmoji}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("autocomplete-wrapper")).not.toBeInTheDocument();
|
||||||
|
|||||||
+15
@@ -234,6 +234,11 @@ describe("WysiwygComposer", () => {
|
|||||||
range: { start: 1, end: 1 },
|
range: { start: 1, end: 1 },
|
||||||
component: <div>community</div>,
|
component: <div>community</div>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
completion: "😄",
|
||||||
|
range: { start: 1, end: 1 },
|
||||||
|
component: <div>😄</div>,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const constructMockProvider = (data: ICompletion[]) =>
|
const constructMockProvider = (data: ICompletion[]) =>
|
||||||
@@ -435,6 +440,16 @@ describe("WysiwygComposer", () => {
|
|||||||
// check that it we still have the initial text
|
// check that it we still have the initial text
|
||||||
expect(screen.getByText(initialInput)).toBeInTheDocument();
|
expect(screen.getByText(initialInput)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("selecting an emoji suggestion inserts the emoji", async () => {
|
||||||
|
await insertMentionInput();
|
||||||
|
|
||||||
|
// select the room suggestion
|
||||||
|
await userEvent.click(screen.getByText("😄"));
|
||||||
|
|
||||||
|
// check that it has inserted the plain text
|
||||||
|
expect(screen.getByText("😄")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("When emoticons should be replaced by emojis", () => {
|
describe("When emoticons should be replaced by emojis", () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
processEmojiReplacement,
|
processEmojiReplacement,
|
||||||
processMention,
|
processMention,
|
||||||
processSelectionChange,
|
processSelectionChange,
|
||||||
|
processTextReplacement,
|
||||||
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
|
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
|
||||||
|
|
||||||
function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
|
||||||
@@ -382,6 +383,95 @@ describe("findSuggestionInText", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processTextReplacement", () => {
|
||||||
|
it("does not change parent hook state if suggestionData is null", () => {
|
||||||
|
const mockSetSuggestionData = jest.fn();
|
||||||
|
const mockSetText = jest.fn();
|
||||||
|
const replacementText = "replacement";
|
||||||
|
|
||||||
|
// call the function with null suggestionData
|
||||||
|
processTextReplacement(replacementText, null, mockSetSuggestionData, mockSetText);
|
||||||
|
|
||||||
|
// check that the parent state setters have not been called
|
||||||
|
expect(mockSetText).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetSuggestionData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change parent hook state if existingContent is null", () => {
|
||||||
|
const mockSetSuggestionData = jest.fn();
|
||||||
|
const mockSetText = jest.fn();
|
||||||
|
const replacementText = "replacement";
|
||||||
|
|
||||||
|
// create a mock node with null textContent
|
||||||
|
const mockNode = {
|
||||||
|
textContent: null,
|
||||||
|
} as unknown as Text;
|
||||||
|
|
||||||
|
const mockSuggestion: Suggestion = {
|
||||||
|
mappedSuggestion: { keyChar: ":", type: "emoji", text: ":)" },
|
||||||
|
node: mockNode,
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// call the function with a node that has null textContent
|
||||||
|
processTextReplacement(replacementText, mockSuggestion, mockSetSuggestionData, mockSetText);
|
||||||
|
|
||||||
|
// check that the parent state setters have not been called
|
||||||
|
expect(mockSetText).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetSuggestionData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can replace text content when both suggestionData and existingContent are valid", () => {
|
||||||
|
const mockSetSuggestionData = jest.fn();
|
||||||
|
const mockSetText = jest.fn();
|
||||||
|
const replacementText = "🙂";
|
||||||
|
const initialText = "Hello :) world";
|
||||||
|
|
||||||
|
// create a div and append a text node to it
|
||||||
|
const editorDiv = document.createElement("div");
|
||||||
|
const textNode = document.createTextNode(initialText);
|
||||||
|
editorDiv.appendChild(textNode);
|
||||||
|
document.body.appendChild(editorDiv);
|
||||||
|
|
||||||
|
const mockSuggestion: Suggestion = {
|
||||||
|
mappedSuggestion: { keyChar: ":", type: "emoji", text: ":)" },
|
||||||
|
node: textNode,
|
||||||
|
startOffset: 6, // position of ":)"
|
||||||
|
endOffset: 8, // end of ":)"
|
||||||
|
};
|
||||||
|
|
||||||
|
// mock document.getSelection
|
||||||
|
const mockSelection = {
|
||||||
|
setBaseAndExtent: jest.fn(),
|
||||||
|
};
|
||||||
|
jest.spyOn(document, "getSelection").mockReturnValue(mockSelection as any);
|
||||||
|
|
||||||
|
// call the function
|
||||||
|
processTextReplacement(replacementText, mockSuggestion, mockSetSuggestionData, mockSetText);
|
||||||
|
|
||||||
|
// check that the text content was updated correctly
|
||||||
|
expect(textNode.textContent).toBe("Hello 🙂 world");
|
||||||
|
|
||||||
|
// check that setText was called with the new content
|
||||||
|
expect(mockSetText).toHaveBeenCalledWith("Hello 🙂 world");
|
||||||
|
|
||||||
|
// check that suggestionData was cleared
|
||||||
|
expect(mockSetSuggestionData).toHaveBeenCalledWith(null);
|
||||||
|
|
||||||
|
// check that the cursor was positioned at the end
|
||||||
|
expect(mockSelection.setBaseAndExtent).toHaveBeenCalledWith(
|
||||||
|
textNode,
|
||||||
|
"Hello 🙂 world".length,
|
||||||
|
textNode,
|
||||||
|
"Hello 🙂 world".length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// clean up
|
||||||
|
document.body.removeChild(editorDiv);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getMappedSuggestion", () => {
|
describe("getMappedSuggestion", () => {
|
||||||
it("returns null when the first character is not / # @", () => {
|
it("returns null when the first character is not / # @", () => {
|
||||||
expect(getMappedSuggestion("Zzz")).toBe(null);
|
expect(getMappedSuggestion("Zzz")).toBe(null);
|
||||||
|
|||||||
@@ -4752,12 +4752,12 @@
|
|||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
uid ""
|
uid ""
|
||||||
|
|
||||||
"@vector-im/matrix-wysiwyg@2.39.0":
|
"@vector-im/matrix-wysiwyg@2.40.0":
|
||||||
version "2.39.0"
|
version "2.40.0"
|
||||||
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.39.0.tgz#a6238e517f23a2f3025d9c65445914771c63b163"
|
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c"
|
||||||
integrity sha512-OROXnzPcQWrCMoUpIrCKEC4FYU+9SsRomUgu+VbJwWtBDkCbfvLD4z6w/mgiADw3iTUpBPgmcWJoGxesFuB20Q==
|
integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm"
|
"@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm"
|
||||||
|
|
||||||
"@vitest/expect@3.2.4":
|
"@vitest/expect@3.2.4":
|
||||||
version "3.2.4"
|
version "3.2.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user