From f20c74cb7648376ac4e1c5c92d9b12d3f7e352d7 Mon Sep 17 00:00:00 2001 From: Simo Date: Fri, 27 Feb 2026 14:49:40 -0400 Subject: [PATCH 1/2] wip Signed-off-by: Simo --- .../lexical/nodes/ImageComponent.test.tsx | 55 +++-- .../lexical/plugins/InsertTextPlugin.test.tsx | 160 ++++++++++++ .../StandaloneImageUrlPreviewPlugin.test.tsx | 156 ++++++++++++ .../UrlPreviewImageTransformer.test.ts | 31 +++ .../CreateDropContent.gifInsert.test.tsx | 188 ++++++++++++++ .../components/waves/CreateDropInput.test.tsx | 37 ++- .../create/lexical/nodes/ImageComponent.tsx | 18 +- .../nodes/urlPreviewImage.constants.ts | 1 + .../lexical/plugins/InsertTextPlugin.tsx | 161 ++++++++++++ .../StandaloneImageUrlPreviewPlugin.tsx | 233 ++++++++++++++++++ .../UrlPreviewImageTransformer.ts | 21 ++ .../part/dropPartMarkdown/handlers/gif.tsx | 20 +- .../view/part/dropPartMarkdown/renderers.tsx | 5 +- components/waves/CreateDropContent.tsx | 45 +--- components/waves/CreateDropInput.tsx | 21 ++ components/waves/drops/gifPreview.ts | 18 ++ 16 files changed, 1094 insertions(+), 76 deletions(-) create mode 100644 __tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx create mode 100644 __tests__/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.test.tsx create mode 100644 __tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts create mode 100644 __tests__/components/waves/CreateDropContent.gifInsert.test.tsx create mode 100644 components/drops/create/lexical/nodes/urlPreviewImage.constants.ts create mode 100644 components/drops/create/lexical/plugins/InsertTextPlugin.tsx create mode 100644 components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.tsx create mode 100644 components/drops/create/lexical/transformers/UrlPreviewImageTransformer.ts create mode 100644 components/waves/drops/gifPreview.ts diff --git a/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx b/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx index b0bf23ec8c..1358288186 100644 --- a/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx +++ b/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx @@ -1,39 +1,58 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import ImageComponent from '@/components/drops/create/lexical/nodes/ImageComponent'; +import { render, screen, fireEvent } from "@testing-library/react"; +import ImageComponent from "@/components/drops/create/lexical/nodes/ImageComponent"; +import { URL_PREVIEW_IMAGE_ALT_TEXT } from "@/components/drops/create/lexical/nodes/urlPreviewImage.constants"; +import { CHAT_GIF_PREVIEW_HEIGHT_PX } from "@/components/waves/drops/gifPreview"; -jest.mock('@/components/distribution-plan-tool/common/CircleLoader', () => ({ +jest.mock("@/components/distribution-plan-tool/common/CircleLoader", () => ({ __esModule: true, default: jest.fn(() =>
), - CircleLoaderSize: { MEDIUM: 'MEDIUM' } + CircleLoaderSize: { MEDIUM: "MEDIUM" }, })); -describe('ImageComponent', () => { +describe("ImageComponent", () => { it('renders loader when src is "loading"', () => { render(); - expect(screen.getByTestId('loader')).toBeInTheDocument(); + expect(screen.getByTestId("loader")).toBeInTheDocument(); }); - it('calculates dimensions when width and height are not provided', () => { + it("calculates dimensions when width and height are not provided", () => { render(); - const img = screen.getByRole('img', { name: 'img' }) as HTMLImageElement; + const img = screen.getByRole("img", { name: "img" }) as HTMLImageElement; - Object.defineProperty(img, 'naturalWidth', { value: 1200 }); - Object.defineProperty(img, 'naturalHeight', { value: 600 }); + Object.defineProperty(img, "naturalWidth", { value: 1200 }); + Object.defineProperty(img, "naturalHeight", { value: 600 }); fireEvent.load(img); - expect(img).toHaveAttribute('width', '800'); - expect(img).toHaveAttribute('height', '400'); + expect(img).toHaveAttribute("width", "800"); + expect(img).toHaveAttribute("height", "400"); }); - it('respects provided width and computes height', () => { + it("respects provided width and computes height", () => { render(); - const img = screen.getByRole('img', { name: 'foo' }) as HTMLImageElement; + const img = screen.getByRole("img", { name: "foo" }) as HTMLImageElement; - Object.defineProperty(img, 'naturalWidth', { value: 600 }); - Object.defineProperty(img, 'naturalHeight', { value: 300 }); + Object.defineProperty(img, "naturalWidth", { value: 600 }); + Object.defineProperty(img, "naturalHeight", { value: 300 }); fireEvent.load(img); - expect(img).toHaveAttribute('width', '200'); - expect(img).toHaveAttribute('height', '100'); + expect(img).toHaveAttribute("width", "200"); + expect(img).toHaveAttribute("height", "100"); + }); + + it("uses markdown-matching fixed height for URL-preview Tenor GIFs", () => { + render( + + ); + const img = screen.getByRole("img") as HTMLImageElement; + + Object.defineProperty(img, "naturalWidth", { value: 1200 }); + Object.defineProperty(img, "naturalHeight", { value: 600 }); + fireEvent.load(img); + + expect(img).toHaveStyle(`height: ${CHAT_GIF_PREVIEW_HEIGHT_PX}px`); + expect(img).toHaveStyle("width: auto"); }); }); diff --git a/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx b/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx new file mode 100644 index 0000000000..9922e80933 --- /dev/null +++ b/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx @@ -0,0 +1,160 @@ +import { render } from "@testing-library/react"; +import React, { createRef } from "react"; +import InsertTextPlugin, { + type InsertTextPluginHandles, +} from "@/components/drops/create/lexical/plugins/InsertTextPlugin"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; + +jest.mock("@lexical/react/LexicalComposerContext"); + +const selectEndMock = jest.fn(); +const getRootMock = jest.fn(() => ({ + selectEnd: selectEndMock, +})); +const getSelectionMock = jest.fn(); +const insertNodesMock = jest.fn(); +const isRangeSelectionMock = jest.fn((selection: unknown) => + Boolean( + (selection as { readonly __isRangeSelection?: boolean } | null) + ?.__isRangeSelection + ) +); +const isTextNodeMock = jest.fn((node: unknown) => + Boolean((node as { readonly __isTextNode?: boolean } | null)?.__isTextNode) +); + +jest.mock("lexical", () => ({ + $createTextNode: jest.fn((text: string) => ({ + __isTextNode: true, + text, + insertAfter: jest.fn(), + selectEnd: jest.fn(), + })), + $getRoot: () => getRootMock(), + $getSelection: () => getSelectionMock(), + $insertNodes: (...args: unknown[]) => insertNodesMock(...args), + $isRangeSelection: (selection: unknown) => isRangeSelectionMock(selection), + $isTextNode: (node: unknown) => isTextNodeMock(node), +})); + +const createImageNodeMock = jest.fn( + (opts: { src: string; altText: string }) => ({ + ...opts, + insertAfter: jest.fn(), + selectNext: jest.fn(), + }) +); + +jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({ + $createImageNode: (opts: { src: string; altText: string }) => + createImageNodeMock(opts), +})); + +const useCtx = useLexicalComposerContext as jest.Mock; + +type MockSelection = { + readonly __isRangeSelection: true; + readonly anchor: { + readonly getNode: () => { + readonly __isTextNode: true; + getTextContent: () => string; + }; + readonly offset: number; + }; + readonly isCollapsed: () => boolean; + readonly insertRawText: jest.Mock; +}; + +const createSelection = ({ + text, + offset, + collapsed = true, +}: { + readonly text: string; + readonly offset: number; + readonly collapsed?: boolean; +}): MockSelection => { + const node = { + __isTextNode: true as const, + getTextContent: () => text, + }; + + return { + __isRangeSelection: true as const, + anchor: { + getNode: () => node, + offset, + }, + isCollapsed: () => collapsed, + insertRawText: jest.fn(), + }; +}; + +describe("InsertTextPlugin", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("inserts text with smart spacing when needed", () => { + const selection = createSelection({ text: "helloworld", offset: 5 }); + getSelectionMock.mockReturnValue(selection); + + const update = jest.fn((fn: () => void) => fn()); + useCtx.mockReturnValue([{ update }]); + + const ref = createRef(); + render(); + + ref.current?.insertTextAtCursor("https://media.tenor.com/abc/tenor.gif", { + smartSpacing: true, + }); + + expect(selection.insertRawText).toHaveBeenCalledWith( + " https://media.tenor.com/abc/tenor.gif " + ); + }); + + it("falls back to end selection when no cursor selection exists", () => { + const fallbackSelection = createSelection({ text: "hello", offset: 5 }); + getSelectionMock + .mockReturnValueOnce(null) + .mockReturnValueOnce(fallbackSelection); + + selectEndMock.mockImplementation(() => { + return undefined; + }); + + const update = jest.fn((fn: () => void) => fn()); + useCtx.mockReturnValue([{ update }]); + + const ref = createRef(); + render(); + + ref.current?.insertTextAtCursor("https://media.tenor.com/abc/tenor.gif"); + + expect(selectEndMock).toHaveBeenCalled(); + expect(fallbackSelection.insertRawText).toHaveBeenCalledWith( + "https://media.tenor.com/abc/tenor.gif" + ); + }); + + it("inserts preview image node for URL insertion", () => { + const selection = createSelection({ text: "hello", offset: 5 }); + getSelectionMock.mockReturnValue(selection); + + const update = jest.fn((fn: () => void) => fn()); + useCtx.mockReturnValue([{ update }]); + + const ref = createRef(); + render(); + + ref.current?.insertImagePreviewFromUrl("https://example.com/cat.gif", { + smartSpacing: true, + }); + + expect(createImageNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ src: "https://example.com/cat.gif" }) + ); + expect(insertNodesMock).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.test.tsx b/__tests__/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.test.tsx new file mode 100644 index 0000000000..6666309833 --- /dev/null +++ b/__tests__/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.test.tsx @@ -0,0 +1,156 @@ +import { render } from "@testing-library/react"; +import StandaloneImageUrlPreviewPlugin from "@/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + COMMAND_PRIORITY_CRITICAL, + KEY_SPACE_COMMAND, + PASTE_COMMAND, +} from "lexical"; +import { parseStandaloneMediaUrl } from "@/components/waves/drops/media-utils"; + +jest.mock("@lexical/react/LexicalComposerContext"); + +const getSelectionMock = jest.fn(); +const isRangeSelectionMock = jest.fn((selection: unknown) => + Boolean(selection) +); +const isTextNodeMock = jest.fn((node: unknown) => + Boolean((node as { readonly __isTextNode?: boolean } | null)?.__isTextNode) +); +const createTextNodeMock = jest.fn((text: string) => ({ + __isTextNode: true, + text, + insertAfter: jest.fn(), + selectEnd: jest.fn(), + selectStart: jest.fn(), +})); +const getRootMock = jest.fn(() => ({ selectEnd: jest.fn() })); + +jest.mock("lexical", () => ({ + $createTextNode: (text: string) => createTextNodeMock(text), + $getRoot: () => getRootMock(), + $getSelection: () => getSelectionMock(), + $isRangeSelection: (selection: unknown) => isRangeSelectionMock(selection), + $isTextNode: (node: unknown) => isTextNodeMock(node), + COMMAND_PRIORITY_CRITICAL: 4, + COMMAND_PRIORITY_NORMAL: 2, + KEY_ENTER_COMMAND: "KEY_ENTER_COMMAND", + KEY_SPACE_COMMAND: "KEY_SPACE_COMMAND", + PASTE_COMMAND: "PASTE_COMMAND", +})); + +const createImageNodeMock = jest.fn( + (opts: { src: string; altText: string }) => ({ + ...opts, + insertAfter: jest.fn(), + selectNext: jest.fn(), + }) +); + +jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({ + $createImageNode: (opts: { src: string; altText: string }) => + createImageNodeMock(opts), +})); + +jest.mock("@/components/waves/drops/media-utils", () => ({ + parseStandaloneMediaUrl: jest.fn(), +})); + +const useLexicalComposerContextMock = useLexicalComposerContext as jest.Mock; +const parseStandaloneMediaUrlMock = parseStandaloneMediaUrl as jest.Mock; + +describe("StandaloneImageUrlPreviewPlugin", () => { + beforeEach(() => { + jest.clearAllMocks(); + parseStandaloneMediaUrlMock.mockImplementation((token: string) => + token.endsWith(".png") || token.endsWith(".gif") + ? { type: "image", url: token, alt: "Media" } + : null + ); + }); + + it("converts standalone image URL token on space key", () => { + const commandHandlers = new Map boolean>(); + const editor = { + registerCommand: jest.fn( + ( + command: string, + handler: (event?: any) => boolean, + priority: number + ) => { + if (priority === COMMAND_PRIORITY_CRITICAL) { + commandHandlers.set(command, handler); + } + return () => {}; + } + ), + update: (fn: () => void) => fn(), + }; + useLexicalComposerContextMock.mockReturnValue([editor]); + + const urlTextNode = { + __isTextNode: true as const, + getTextContent: () => "https://example.com/cat.gif", + replace: jest.fn(), + }; + getSelectionMock.mockReturnValue({ + isCollapsed: () => true, + anchor: { + getNode: () => urlTextNode, + offset: "https://example.com/cat.gif".length, + }, + }); + + render(); + + const preventDefault = jest.fn(); + const handled = commandHandlers.get(KEY_SPACE_COMMAND)?.({ + preventDefault, + }); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(urlTextNode.replace).toHaveBeenCalled(); + expect(createImageNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ src: "https://example.com/cat.gif" }) + ); + }); + + it("converts pasted standalone image URLs into preview image node", () => { + const commandHandlers = new Map boolean>(); + const editor = { + registerCommand: jest.fn( + (command: string, handler: (event?: any) => boolean) => { + commandHandlers.set(command, handler); + return () => {}; + } + ), + update: (fn: () => void) => fn(), + }; + useLexicalComposerContextMock.mockReturnValue([editor]); + + const insertNodes = jest.fn(); + getSelectionMock.mockReturnValue({ + isCollapsed: () => true, + insertNodes, + anchor: { getNode: () => null, offset: 0 }, + }); + + render(); + + const preventDefault = jest.fn(); + const handled = commandHandlers.get(PASTE_COMMAND)?.({ + preventDefault, + clipboardData: { + files: [], + getData: () => "https://example.com/banner.png", + }, + }); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(insertNodes).toHaveBeenCalledWith([ + expect.objectContaining({ src: "https://example.com/banner.png" }), + ]); + }); +}); diff --git a/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts b/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts new file mode 100644 index 0000000000..dbbb45bfd3 --- /dev/null +++ b/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts @@ -0,0 +1,31 @@ +import { URL_PREVIEW_IMAGE_TRANSFORMER } from "@/components/drops/create/lexical/transformers/UrlPreviewImageTransformer"; +import { URL_PREVIEW_IMAGE_ALT_TEXT } from "@/components/drops/create/lexical/nodes/urlPreviewImage.constants"; + +jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({ + $isImageNode: jest.fn((n) => n && n.type === "image"), + ImageNode: class {}, +})); + +describe("URL_PREVIEW_IMAGE_TRANSFORMER", () => { + it("exports plain URL for preview-marked image nodes", () => { + const node: any = { + type: "image", + getAltText: () => URL_PREVIEW_IMAGE_ALT_TEXT, + getSrc: () => "https://media.tenor.com/abc/tenor.gif", + }; + + expect(URL_PREVIEW_IMAGE_TRANSFORMER.export?.(node)).toBe( + "https://media.tenor.com/abc/tenor.gif" + ); + }); + + it("returns null for non-preview image nodes", () => { + const node: any = { + type: "image", + getAltText: () => "Seize", + getSrc: () => "https://example.com/image.png", + }; + + expect(URL_PREVIEW_IMAGE_TRANSFORMER.export?.(node)).toBeNull(); + }); +}); diff --git a/__tests__/components/waves/CreateDropContent.gifInsert.test.tsx b/__tests__/components/waves/CreateDropContent.gifInsert.test.tsx new file mode 100644 index 0000000000..052b6624c9 --- /dev/null +++ b/__tests__/components/waves/CreateDropContent.gifInsert.test.tsx @@ -0,0 +1,188 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CreateDropContent from "@/components/waves/CreateDropContent"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; + +const mockInsertTextAtCursor = jest.fn(); +const mockInsertImagePreviewFromUrl = jest.fn(); +const mockFocus = jest.fn(); +const mockSubmitDrop = jest.fn(); +const mockSend = jest.fn(); + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +jest.mock("next/dynamic", () => ({ + __esModule: true, + default: () => () => null, +})); + +jest.mock("react-redux", () => ({ + useSelector: () => null, +})); + +jest.mock("framer-motion", () => { + const React = require("react"); + return { + __esModule: true, + motion: { + div: React.forwardRef(function Div( + { children, ...props }: { children: React.ReactNode }, + ref: React.Ref + ) { + return React.createElement("div", { ...props, ref }, children); + }), + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + }; +}); + +jest.mock("@/hooks/useDeviceInfo", () => ({ + __esModule: true, + default: () => ({ isApp: false }), +})); + +jest.mock("@/components/waves/CreateDropActions", () => ({ + __esModule: true, + default: (props: any) => ( + + ), +})); + +jest.mock("@/components/waves/CreateDropInput", () => { + const React = require("react"); + return React.forwardRef((_props: any, ref: any) => { + React.useImperativeHandle(ref, () => ({ + clearEditorState: jest.fn(), + insertTextAtCursor: mockInsertTextAtCursor, + insertImagePreviewFromUrl: mockInsertImagePreviewFromUrl, + focus: mockFocus, + })); + return
; + }); +}); + +jest.mock("@/components/waves/CreateDropReplyingWrapper", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/waves/CreateDropMetadata", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/waves/CreateDropContentRequirements", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/waves/CreateDropContentFiles", () => ({ + __esModule: true, + CreateDropContentFiles: () =>
, +})); + +jest.mock("@/components/waves/CreateDropDropModeToggle", () => ({ + __esModule: true, + CreateDropDropModeToggle: () =>
, +})); + +jest.mock("@/components/waves/CreateDropSubmit", () => ({ + __esModule: true, + CreateDropSubmit: () =>
, +})); + +jest.mock("@/contexts/wave/MyStreamContext", () => ({ + __esModule: true, + useMyStream: () => ({ processIncomingDrop: jest.fn() }), +})); + +jest.mock("@/hooks/drops/useDropSignature", () => ({ + __esModule: true, + useDropSignature: () => ({ signDrop: jest.fn() }), +})); + +jest.mock("@/hooks/useWave", () => ({ + __esModule: true, + useWave: () => ({ isMemesWave: false }), +})); + +jest.mock("@/services/websocket", () => ({ + __esModule: true, + useWebSocket: () => ({ send: mockSend }), +})); + +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + __esModule: true, + useSeizeConnectContext: () => ({ + isSafeWallet: false, + address: null, + }), +})); + +jest.mock("@/components/waves/hooks/useDropMetadata", () => ({ + __esModule: true, + generateMetadataId: () => "metadata-id", + useDropMetadata: () => ({ + metadata: [], + setMetadata: jest.fn(), + initialMetadata: [], + }), +})); + +describe("CreateDropContent GIF insert behavior", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("inserts selected GIF URL into editor and does not submit immediately", async () => { + const wave = { + id: "wave-1", + wave: { type: ApiWaveType.Chat }, + chat: { enabled: true }, + participation: { + required_metadata: [], + required_media: [], + signature_required: false, + terms: null, + }, + } as any; + + render( + + ); + + await userEvent.click(screen.getByTestId("select-gif")); + + expect(mockInsertImagePreviewFromUrl).toHaveBeenCalledWith( + "https://media.tenor.com/abc/tenor.gif", + { smartSpacing: true } + ); + expect(mockFocus).toHaveBeenCalled(); + expect(mockSubmitDrop).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/waves/CreateDropInput.test.tsx b/__tests__/components/waves/CreateDropInput.test.tsx index 70a962cdf0..8391929636 100644 --- a/__tests__/components/waves/CreateDropInput.test.tsx +++ b/__tests__/components/waves/CreateDropInput.test.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { createRef } from "react"; import { render, screen } from "@testing-library/react"; import CreateDropInput from "@/components/waves/CreateDropInput"; import { ActiveDropAction } from "@/types/dropInteractionTypes"; @@ -89,6 +89,17 @@ jest.mock( "@/components/drops/create/lexical/plugins/DragDropPastePlugin", () => ({ __esModule: true, default: () =>
}) ); +jest.mock("@/components/drops/create/lexical/plugins/InsertTextPlugin", () => ({ + __esModule: true, + default: React.forwardRef(() =>
), +})); +jest.mock( + "@/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin", + () => ({ + __esModule: true, + default: () =>
, + }) +); jest.mock( "@/components/drops/create/lexical/plugins/enter/EnterKeyPlugin", () => ({ __esModule: true, default: () =>
}) @@ -162,3 +173,27 @@ it("renders validation helper text when provided", () => { "URL must be from superrare.com, manifold.xyz, opensea.io, transient.xyz, or foundation.app." ); }); + +it("exposes insertTextAtCursor on the forwarded ref", () => { + const ref = createRef(); + + render( + + ); + + expect(typeof ref.current?.insertTextAtCursor).toBe("function"); + expect(typeof ref.current?.insertImagePreviewFromUrl).toBe("function"); +}); diff --git a/components/drops/create/lexical/nodes/ImageComponent.tsx b/components/drops/create/lexical/nodes/ImageComponent.tsx index a8230b9a22..19f296de42 100644 --- a/components/drops/create/lexical/nodes/ImageComponent.tsx +++ b/components/drops/create/lexical/nodes/ImageComponent.tsx @@ -4,6 +4,11 @@ import React, { useState, type JSX } from "react"; import CircleLoader, { CircleLoaderSize, } from "@/components/distribution-plan-tool/common/CircleLoader"; +import { + CHAT_GIF_PREVIEW_HEIGHT_PX, + isTenorGifUrl, +} from "@/components/waves/drops/gifPreview"; +import { URL_PREVIEW_IMAGE_ALT_TEXT } from "./urlPreviewImage.constants"; interface ImageComponentProps { readonly src: string; @@ -18,6 +23,8 @@ export default function ImageComponent({ width, height, }: ImageComponentProps): JSX.Element { + const isUrlPreviewGif = + altText === URL_PREVIEW_IMAGE_ALT_TEXT && isTenorGifUrl(src); const [dimensions, setDimensions] = useState({ width: width ?? 0, height: height ?? 0, @@ -55,7 +62,16 @@ export default function ImageComponent({ width={dimensions.width} height={dimensions.height} onLoad={handleImageLoad} - style={{ maxWidth: "100%", height: "auto" }} + style={ + isUrlPreviewGif + ? { + maxWidth: "100%", + width: "auto", + height: `${CHAT_GIF_PREVIEW_HEIGHT_PX}px`, + objectFit: "contain", + } + : { maxWidth: "100%", height: "auto" } + } /> ); } diff --git a/components/drops/create/lexical/nodes/urlPreviewImage.constants.ts b/components/drops/create/lexical/nodes/urlPreviewImage.constants.ts new file mode 100644 index 0000000000..e226159345 --- /dev/null +++ b/components/drops/create/lexical/nodes/urlPreviewImage.constants.ts @@ -0,0 +1 @@ +export const URL_PREVIEW_IMAGE_ALT_TEXT = "__URL_PREVIEW_IMAGE__"; diff --git a/components/drops/create/lexical/plugins/InsertTextPlugin.tsx b/components/drops/create/lexical/plugins/InsertTextPlugin.tsx new file mode 100644 index 0000000000..90c923b0f0 --- /dev/null +++ b/components/drops/create/lexical/plugins/InsertTextPlugin.tsx @@ -0,0 +1,161 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $createTextNode, + $getRoot, + $getSelection, + $insertNodes, + $isRangeSelection, + $isTextNode, + type RangeSelection, +} from "lexical"; +import { forwardRef, useImperativeHandle } from "react"; +import { $createImageNode } from "../nodes/ImageNode"; +import { URL_PREVIEW_IMAGE_ALT_TEXT } from "../nodes/urlPreviewImage.constants"; + +export interface InsertTextOptions { + readonly smartSpacing?: boolean; +} + +export interface InsertTextPluginHandles { + insertTextAtCursor: (text: string, options?: InsertTextOptions) => void; + insertImagePreviewFromUrl: (url: string, options?: InsertTextOptions) => void; +} + +const hasNonWhitespace = (value: string | undefined): boolean => + Boolean(value && /\S/.test(value)); + +const applySmartSpacing = ( + selection: RangeSelection, + text: string, + smartSpacing: boolean +): string => { + if (!smartSpacing || !text.length || !selection.isCollapsed()) { + return text; + } + + const anchorNode = selection.anchor.getNode(); + if (!$isTextNode(anchorNode)) { + return text; + } + + const content = anchorNode.getTextContent(); + const offset = selection.anchor.offset; + + const beforeChar = offset > 0 ? content[offset - 1] : undefined; + const afterChar = offset < content.length ? content[offset] : undefined; + const firstChar = text[0]; + const lastChar = text[text.length - 1]; + + const prefix = hasNonWhitespace(beforeChar) && hasNonWhitespace(firstChar); + const suffix = hasNonWhitespace(afterChar) && hasNonWhitespace(lastChar); + + return `${prefix ? " " : ""}${text}${suffix ? " " : ""}`; +}; + +const getSpacingForSelection = ( + selection: RangeSelection, + smartSpacing: boolean +): { readonly leading: boolean; readonly trailing: boolean } => { + if (!smartSpacing || !selection.isCollapsed()) { + return { leading: false, trailing: false }; + } + + const anchorNode = selection.anchor.getNode(); + if (!$isTextNode(anchorNode)) { + return { leading: false, trailing: false }; + } + + const content = anchorNode.getTextContent(); + const offset = selection.anchor.offset; + const beforeChar = offset > 0 ? content[offset - 1] : undefined; + const afterChar = offset < content.length ? content[offset] : undefined; + + return { + leading: hasNonWhitespace(beforeChar), + trailing: hasNonWhitespace(afterChar), + }; +}; + +const InsertTextPlugin = forwardRef((_, ref) => { + const [editor] = useLexicalComposerContext(); + + const insertTextAtCursor = (text: string, options?: InsertTextOptions) => { + if (!text.length) { + return; + } + + editor.update(() => { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) { + $getRoot().selectEnd(); + selection = $getSelection(); + } + + if (!$isRangeSelection(selection)) { + return; + } + + const textToInsert = applySmartSpacing( + selection, + text, + Boolean(options?.smartSpacing) + ); + + selection.insertRawText(textToInsert); + }); + }; + + const insertImagePreviewFromUrl = ( + url: string, + options?: InsertTextOptions + ) => { + const normalizedUrl = url.trim(); + if (!normalizedUrl.length) { + return; + } + + editor.update(() => { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) { + $getRoot().selectEnd(); + selection = $getSelection(); + } + + if (!$isRangeSelection(selection)) { + return; + } + + const { leading, trailing } = getSpacingForSelection( + selection, + Boolean(options?.smartSpacing) + ); + + const nodes = [ + ...(leading ? [$createTextNode(" ")] : []), + $createImageNode({ + src: normalizedUrl, + altText: URL_PREVIEW_IMAGE_ALT_TEXT, + }), + ...(trailing ? [$createTextNode(" ")] : []), + ]; + + $insertNodes(nodes); + + const trailingSpaceNode = trailing ? nodes[nodes.length - 1] : null; + if ($isTextNode(trailingSpaceNode)) { + trailingSpaceNode.selectEnd(); + } + }); + }; + + useImperativeHandle(ref, () => ({ + insertTextAtCursor, + insertImagePreviewFromUrl, + })); + + return <>; +}); + +InsertTextPlugin.displayName = "InsertTextPlugin"; + +export default InsertTextPlugin; diff --git a/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.tsx b/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.tsx new file mode 100644 index 0000000000..01991bd992 --- /dev/null +++ b/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_NORMAL, + KEY_ENTER_COMMAND, + KEY_SPACE_COMMAND, + PASTE_COMMAND, +} from "lexical"; +import { useEffect } from "react"; +import { parseStandaloneMediaUrl } from "@/components/waves/drops/media-utils"; +import { $createImageNode } from "../nodes/ImageNode"; +import { URL_PREVIEW_IMAGE_ALT_TEXT } from "../nodes/urlPreviewImage.constants"; + +const WHITESPACE_REGEX = /\s/; + +const getTokenEndingAtOffset = ( + text: string, + offset: number +): { + readonly start: number; + readonly end: number; + readonly token: string; +} | null => { + if (offset <= 0) { + return null; + } + + let start = offset; + while (start > 0 && !WHITESPACE_REGEX.test(text[start - 1]!)) { + start -= 1; + } + + const token = text.slice(start, offset); + if (!token.length) { + return null; + } + + return { start, end: offset, token }; +}; + +const isPreviewableImageUrl = (token: string): boolean => { + const media = parseStandaloneMediaUrl(token); + return media?.type === "image"; +}; + +const replaceTokenWithPreviewNode = ({ + textNode, + start, + end, + url, + appendTrailingSpace, +}: { + readonly textNode: ReturnType; + readonly start: number; + readonly end: number; + readonly url: string; + readonly appendTrailingSpace: boolean; +}): void => { + const original = textNode.getTextContent(); + const beforeText = original.slice(0, start); + const afterText = original.slice(end); + + const beforeNode = beforeText.length ? $createTextNode(beforeText) : null; + const imageNode = $createImageNode({ + src: url, + altText: URL_PREVIEW_IMAGE_ALT_TEXT, + }); + const trailingSpaceNode = appendTrailingSpace ? $createTextNode(" ") : null; + const afterNode = afterText.length ? $createTextNode(afterText) : null; + + const nodes = [ + ...(beforeNode ? [beforeNode] : []), + imageNode, + ...(trailingSpaceNode ? [trailingSpaceNode] : []), + ...(afterNode ? [afterNode] : []), + ]; + + const [firstNode, ...restNodes] = nodes; + if (!firstNode) { + return; + } + + textNode.replace(firstNode); + let previousNode = firstNode; + for (const node of restNodes) { + previousNode.insertAfter(node); + previousNode = node; + } + + if (trailingSpaceNode) { + trailingSpaceNode.selectEnd(); + return; + } + + if (afterNode) { + afterNode.selectStart(); + return; + } + + imageNode.selectNext(); +}; + +const convertCurrentTokenToPreviewNode = ( + appendTrailingSpace: boolean +): boolean => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return false; + } + + const anchorNode = selection.anchor.getNode(); + if (!$isTextNode(anchorNode)) { + return false; + } + + const tokenRange = getTokenEndingAtOffset( + anchorNode.getTextContent(), + selection.anchor.offset + ); + if (!tokenRange) { + return false; + } + + if (!isPreviewableImageUrl(tokenRange.token)) { + return false; + } + + replaceTokenWithPreviewNode({ + textNode: anchorNode, + start: tokenRange.start, + end: tokenRange.end, + url: tokenRange.token, + appendTrailingSpace, + }); + + return true; +}; + +export default function StandaloneImageUrlPreviewPlugin(): null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const unregisterSpace = editor.registerCommand( + KEY_SPACE_COMMAND, + (event) => { + let converted = false; + editor.update(() => { + converted = convertCurrentTokenToPreviewNode(true); + }); + + if (converted) { + event?.preventDefault(); + return true; + } + + return false; + }, + COMMAND_PRIORITY_CRITICAL + ); + + const unregisterEnter = editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event?.shiftKey) { + return false; + } + + editor.update(() => { + convertCurrentTokenToPreviewNode(false); + }); + + return false; + }, + COMMAND_PRIORITY_CRITICAL + ); + + const unregisterPaste = editor.registerCommand( + PASTE_COMMAND, + (event) => { + const clipboardData = event.clipboardData; + if (!clipboardData) { + return false; + } + + if (clipboardData.files.length > 0) { + return false; + } + + const text = clipboardData.getData("text/plain").trim(); + if (!isPreviewableImageUrl(text)) { + return false; + } + + event.preventDefault(); + editor.update(() => { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) { + $getRoot().selectEnd(); + selection = $getSelection(); + } + + if (!$isRangeSelection(selection)) { + return; + } + + selection.insertNodes([ + $createImageNode({ + src: text, + altText: URL_PREVIEW_IMAGE_ALT_TEXT, + }), + ]); + }); + return true; + }, + COMMAND_PRIORITY_NORMAL + ); + + return () => { + unregisterSpace(); + unregisterEnter(); + unregisterPaste(); + }; + }, [editor]); + + return null; +} diff --git a/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.ts b/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.ts new file mode 100644 index 0000000000..3e2a920d59 --- /dev/null +++ b/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.ts @@ -0,0 +1,21 @@ +import type { Transformer } from "@lexical/markdown"; +import { $isImageNode, ImageNode } from "../nodes/ImageNode"; +import { URL_PREVIEW_IMAGE_ALT_TEXT } from "../nodes/urlPreviewImage.constants"; + +export const URL_PREVIEW_IMAGE_TRANSFORMER: Transformer = { + type: "element", + dependencies: [ImageNode], + export: (node) => { + if (!$isImageNode(node)) { + return null; + } + + if (node.getAltText() !== URL_PREVIEW_IMAGE_ALT_TEXT) { + return null; + } + + return node.getSrc(); + }, + regExp: /(?:)/, + replace: () => {}, +}; diff --git a/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx b/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx index 1c4c17ec1e..b92222051f 100644 --- a/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx +++ b/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx @@ -1,28 +1,12 @@ import { renderGifEmbed } from "../renderers"; import type { LinkHandler } from "../linkTypes"; import type { LinkPreviewVariant } from "@/components/waves/LinkPreviewContext"; - -const TENOR_HOST = "media.tenor.com"; - -const isTenorGif = (href: string): boolean => { - try { - const url = new URL(href); - const hostname = url.hostname.toLowerCase(); - if (hostname !== TENOR_HOST) { - return false; - } - - const pathname = url.pathname.toLowerCase(); - return pathname.endsWith(".gif"); - } catch { - return false; - } -}; +import { isTenorGifUrl } from "@/components/waves/drops/gifPreview"; export const createGifHandler = (options?: { readonly linkPreviewVariant?: LinkPreviewVariant; }): LinkHandler => ({ - match: isTenorGif, + match: isTenorGifUrl, render: (href) => renderGifEmbed(href, { fixedSize: options?.linkPreviewVariant !== "home", diff --git a/components/drops/view/part/dropPartMarkdown/renderers.tsx b/components/drops/view/part/dropPartMarkdown/renderers.tsx index 1041a6edd8..64dcad9cb5 100644 --- a/components/drops/view/part/dropPartMarkdown/renderers.tsx +++ b/components/drops/view/part/dropPartMarkdown/renderers.tsx @@ -8,6 +8,7 @@ import { getWaveRoute } from "@/helpers/navigation.helpers"; import LinkHandlerFrame from "@/components/waves/LinkHandlerFrame"; import WaveDropQuoteWithDropId from "@/components/waves/drops/WaveDropQuoteWithDropId"; import WaveDropQuoteWithSerialNo from "@/components/waves/drops/WaveDropQuoteWithSerialNo"; +import { CHAT_GIF_PREVIEW_HEIGHT_PX } from "@/components/waves/drops/gifPreview"; import ExpandableTweetPreview from "@/components/tweets/ExpandableTweetPreview"; import type { TweetPreviewMode } from "@/components/tweets/TweetPreviewModeContext"; import { ensureTwitterLink } from "./twitter"; @@ -57,8 +58,6 @@ const renderTweetEmbed = ( ); }; -const CHAT_GIF_HEIGHT = 180; - interface GifEmbedOptions { readonly fixedSize?: boolean | undefined; } @@ -74,7 +73,7 @@ const renderGifEmbed = ( src={url} alt="Embedded GIF" className="tw-block tw-w-auto tw-max-w-full tw-rounded-xl tw-object-contain" - style={{ height: `${CHAT_GIF_HEIGHT}px` }} + style={{ height: `${CHAT_GIF_PREVIEW_HEIGHT_PX}px` }} loading="lazy" /> ); diff --git a/components/waves/CreateDropContent.tsx b/components/waves/CreateDropContent.tsx index 3fde7dc975..2965f3ba17 100644 --- a/components/waves/CreateDropContent.tsx +++ b/components/waves/CreateDropContent.tsx @@ -39,6 +39,7 @@ import { AuthContext } from "../auth/Auth"; import { HASHTAG_TRANSFORMER } from "../drops/create/lexical/transformers/HastagTransformer"; import { IMAGE_TRANSFORMER } from "../drops/create/lexical/transformers/ImageTransformer"; import { MENTION_TRANSFORMER } from "../drops/create/lexical/transformers/MentionTransformer"; +import { URL_PREVIEW_IMAGE_TRANSFORMER } from "../drops/create/lexical/transformers/UrlPreviewImageTransformer"; import { WAVE_MENTION_TRANSFORMER } from "../drops/create/lexical/transformers/WaveMentionTransformer"; import { ReactQueryWrapperContext } from "../react-query-wrapper/ReactQueryWrapper"; import CreateDropActions from "./CreateDropActions"; @@ -422,6 +423,7 @@ const CreateDropContent: React.FC = ({ MENTION_TRANSFORMER, HASHTAG_TRANSFORMER, WAVE_MENTION_TRANSFORMER, + URL_PREVIEW_IMAGE_TRANSFORMER, IMAGE_TRANSFORMER, EMOJI_TRANSFORMER, ]) @@ -544,38 +546,6 @@ const CreateDropContent: React.FC = ({ return null; }; - const replyTo = getReplyTo(); - const replyToObj = replyTo ? { reply_to: replyTo } : {}; - - const createGifDrop = (gif: string): CreateDropConfig => { - return { - title: null, - drop_type: isDropMode ? ApiDropType.Participatory : ApiDropType.Chat, - ...replyToObj, - parts: [ - ...(drop?.parts ?? []), - { - content: gif, - quoted_drop: - activeDrop?.action === ActiveDropAction.QUOTE - ? { - drop_id: activeDrop.drop.id, - drop_part_id: activeDrop.partId, - } - : null, - media: files, - }, - ], - mentioned_users: [], - mentioned_waves: [], - referenced_nfts: [], - metadata: [], - signature: null, - is_safe_signature: isSafeWallet, - signer_address: address ?? "", - }; - }; - const createCurrentDrop = ( markdown: string | null, allMentions: ApiDropMentionedUser[], @@ -901,11 +871,16 @@ const CreateDropContent: React.FC = ({ await prepareAndSubmitDrop(getUpdatedDrop()); }; - const onGifDrop = async (gif: string): Promise => { - if (submitting) { + const onGifDrop = (gif: string): void => { + const trimmedGif = gif.trim(); + if (submitting || !trimmedGif.length) { return; } - await prepareAndSubmitDrop(createGifDrop(gif)); + + createDropInputRef.current?.insertImagePreviewFromUrl(trimmedGif, { + smartSpacing: true, + }); + createDropInputRef.current?.focus(); }; const focusInputWithDelay = (delay: number) => { diff --git a/components/waves/CreateDropInput.tsx b/components/waves/CreateDropInput.tsx index f9c6f541aa..16f73bb1f5 100644 --- a/components/waves/CreateDropInput.tsx +++ b/components/waves/CreateDropInput.tsx @@ -59,9 +59,16 @@ import EmojiPlugin from "../drops/create/lexical/plugins/emoji/EmojiPlugin"; import { EmojiNode } from "../drops/create/lexical/nodes/EmojiNode"; import { SAFE_MARKDOWN_TRANSFORMERS } from "@/components/drops/create/lexical/transformers/markdownTransformers"; import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/PlainTextPastePlugin"; +import InsertTextPlugin, { + type InsertTextOptions, + type InsertTextPluginHandles, +} from "../drops/create/lexical/plugins/InsertTextPlugin"; +import StandaloneImageUrlPreviewPlugin from "../drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin"; export interface CreateDropInputHandles { clearEditorState: () => void; + insertTextAtCursor: (text: string, options?: InsertTextOptions) => void; + insertImagePreviewFromUrl: (url: string, options?: InsertTextOptions) => void; focus: () => void; } @@ -192,13 +199,25 @@ const CreateDropInput = forwardRef< } const clearEditorRef = useRef(null); + const insertTextRef = useRef(null); const editorRef = useRef(null); const clearEditorState = () => { clearEditorRef.current?.clearEditorState(); }; + const insertTextAtCursor = (text: string, options?: InsertTextOptions) => { + insertTextRef.current?.insertTextAtCursor(text, options); + }; + const insertImagePreviewFromUrl = ( + url: string, + options?: InsertTextOptions + ) => { + insertTextRef.current?.insertImagePreviewFromUrl(url, options); + }; useImperativeHandle(ref, () => ({ clearEditorState, + insertTextAtCursor, + insertImagePreviewFromUrl, focus: () => { ( editorRef.current?.querySelector( @@ -309,12 +328,14 @@ const CreateDropInput = forwardRef< + + { + try { + const url = new URL(href); + const hostname = url.hostname.toLowerCase(); + if (hostname !== TENOR_HOST) { + return false; + } + + const pathname = url.pathname.toLowerCase(); + return pathname.endsWith(".gif"); + } catch { + return false; + } +}; From 0be984e4064ee8cef52c1357d40dbc8817f9fb36 Mon Sep 17 00:00:00 2001 From: Simo Date: Fri, 27 Feb 2026 15:53:40 -0400 Subject: [PATCH 2/2] wip Signed-off-by: Simo --- .../lexical/nodes/ImageComponent.test.tsx | 6 +- .../create/lexical/nodes/ImageNode.test.tsx | 60 +++++++++++---- .../lexical/plugins/InsertTextPlugin.test.tsx | 75 +++++++++++++++++-- .../UrlPreviewImageTransformer.test.ts | 24 ++++++ .../create/lexical/nodes/ImageComponent.tsx | 4 +- .../drops/create/lexical/nodes/ImageNode.tsx | 13 +++- .../lexical/plugins/InsertTextPlugin.tsx | 58 ++++++++++++-- .../UrlPreviewImageTransformer.ts | 11 ++- 8 files changed, 221 insertions(+), 30 deletions(-) diff --git a/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx b/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx index 1358288186..e150aecd45 100644 --- a/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx +++ b/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx @@ -40,13 +40,15 @@ describe("ImageComponent", () => { }); it("uses markdown-matching fixed height for URL-preview Tenor GIFs", () => { - render( + const { container } = render( ); - const img = screen.getByRole("img") as HTMLImageElement; + const img = container.querySelector("img") as HTMLImageElement; + expect(img).toBeTruthy(); + expect(img.getAttribute("alt")).toBe(""); Object.defineProperty(img, "naturalWidth", { value: 1200 }); Object.defineProperty(img, "naturalHeight", { value: 600 }); diff --git a/__tests__/components/drops/create/lexical/nodes/ImageNode.test.tsx b/__tests__/components/drops/create/lexical/nodes/ImageNode.test.tsx index a93f194806..5550351523 100644 --- a/__tests__/components/drops/create/lexical/nodes/ImageNode.test.tsx +++ b/__tests__/components/drops/create/lexical/nodes/ImageNode.test.tsx @@ -1,29 +1,61 @@ -import { ImageNode, $createImageNode, $isImageNode } from '@/components/drops/create/lexical/nodes/ImageNode'; +import { + ImageNode, + $createImageNode, + $isImageNode, +} from "@/components/drops/create/lexical/nodes/ImageNode"; -jest.mock('lexical', () => { +jest.mock("lexical", () => { class DecoratorNode { constructor() {} - createDOM() { return document.createElement('span'); } - updateDOM() { return false; } - exportJSON() { return {}; } + createDOM() { + return document.createElement("span"); + } + updateDOM() { + return false; + } + exportJSON() { + return {}; + } } return { DecoratorNode, $applyNodeReplacement: (n: any) => n }; }); -describe('ImageNode', () => { - it('exports and imports JSON', () => { - const node = $createImageNode({ src: 'img.png', altText: 'alt', width: 10, height: 20 }); +describe("ImageNode", () => { + it("exports and imports JSON", () => { + const node = $createImageNode({ + src: "img.png", + altText: "alt", + width: 10, + height: 20, + }); const json = node.exportJSON(); - expect(json).toEqual({ type: 'image', version: 1, src: 'img.png', altText: 'alt', width: 10, height: 20 }); + expect(json).toEqual({ + type: "image", + version: 1, + src: "img.png", + altText: "alt", + width: 10, + height: 20, + }); const imported = ImageNode.importJSON(json); - expect(imported.getSrc()).toBe('img.png'); + expect(imported.getSrc()).toBe("img.png"); expect($isImageNode(imported)).toBe(true); }); - it('createDOM adds class from theme', () => { - const node = $createImageNode({ src: 'a.png' }); - const span = node.createDOM({ theme: { image: 'cls' } } as any); - expect(span.className).toBe('cls'); + it("createDOM adds class from theme", () => { + const node = $createImageNode({ src: "a.png" }); + const span = node.createDOM({ theme: { image: "cls" } } as any); + expect(span.className).toBe("cls"); expect(node.updateDOM(node, span, {} as any)).toBe(false); }); + + it("uses loading fallback only for loading placeholder src", () => { + const loadingNode = $createImageNode({ src: "loading" }); + const loadingDecorated = loadingNode.decorate() as any; + expect(loadingDecorated.props.fallback).toBeTruthy(); + + const regularNode = $createImageNode({ src: "a.png" }); + const regularDecorated = regularNode.decorate() as any; + expect(regularDecorated.props.fallback).toBeNull(); + }); }); diff --git a/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx b/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx index 9922e80933..db345dd301 100644 --- a/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx +++ b/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx @@ -12,6 +12,7 @@ const getRootMock = jest.fn(() => ({ selectEnd: selectEndMock, })); const getSelectionMock = jest.fn(); +const getNodeByKeyMock = jest.fn(); const insertNodesMock = jest.fn(); const isRangeSelectionMock = jest.fn((selection: unknown) => Boolean( @@ -30,6 +31,7 @@ jest.mock("lexical", () => ({ insertAfter: jest.fn(), selectEnd: jest.fn(), })), + $getNodeByKey: (key: string) => getNodeByKeyMock(key), $getRoot: () => getRootMock(), $getSelection: () => getSelectionMock(), $insertNodes: (...args: unknown[]) => insertNodesMock(...args), @@ -37,15 +39,27 @@ jest.mock("lexical", () => ({ $isTextNode: (node: unknown) => isTextNodeMock(node), })); +let imageNodeCounter = 0; const createImageNodeMock = jest.fn( - (opts: { src: string; altText: string }) => ({ - ...opts, - insertAfter: jest.fn(), - selectNext: jest.fn(), - }) + (opts: { src: string; altText: string }) => { + const key = `image-${++imageNodeCounter}`; + return { + __isImageNode: true, + ...opts, + getSrc: () => opts.src, + getKey: () => key, + replace: jest.fn(), + insertAfter: jest.fn(), + selectNext: jest.fn(), + }; + } ); jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({ + $isImageNode: (node: unknown) => + Boolean( + (node as { readonly __isImageNode?: boolean } | null)?.__isImageNode + ), $createImageNode: (opts: { src: string; altText: string }) => createImageNodeMock(opts), })); @@ -93,6 +107,7 @@ const createSelection = ({ describe("InsertTextPlugin", () => { beforeEach(() => { jest.clearAllMocks(); + imageNodeCounter = 0; }); it("inserts text with smart spacing when needed", () => { @@ -157,4 +172,54 @@ describe("InsertTextPlugin", () => { ); expect(insertNodesMock).toHaveBeenCalled(); }); + + it("inserts loading placeholder for Tenor GIF and swaps to preview URL", () => { + const OriginalImage = global.Image; + class MockImage { + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + set src(_value: string) { + this.onload?.(); + } + } + (global as typeof globalThis & { Image: typeof Image }).Image = + MockImage as unknown as typeof Image; + + try { + const selection = createSelection({ text: "hello", offset: 5 }); + getSelectionMock.mockReturnValue(selection); + + const placeholderNode = { + __isImageNode: true as const, + getSrc: () => "loading", + replace: jest.fn(), + }; + getNodeByKeyMock.mockReturnValue(placeholderNode); + + const update = jest.fn((fn: () => void) => fn()); + useCtx.mockReturnValue([{ update }]); + + const ref = createRef(); + render(); + + ref.current?.insertImagePreviewFromUrl( + "https://media.tenor.com/abc/tenor.gif", + { smartSpacing: true } + ); + + expect(createImageNodeMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ src: "loading" }) + ); + expect(getNodeByKeyMock).toHaveBeenCalled(); + expect(placeholderNode.replace).toHaveBeenCalledWith( + expect.objectContaining({ + src: "https://media.tenor.com/abc/tenor.gif", + }) + ); + } finally { + (global as typeof globalThis & { Image: typeof Image }).Image = + OriginalImage; + } + }); }); diff --git a/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts b/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts index dbbb45bfd3..5b591f722a 100644 --- a/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts +++ b/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts @@ -28,4 +28,28 @@ describe("URL_PREVIEW_IMAGE_TRANSFORMER", () => { expect(URL_PREVIEW_IMAGE_TRANSFORMER.export?.(node)).toBeNull(); }); + + it("uses a non-matching defensive regex for import-side behavior", () => { + expect(URL_PREVIEW_IMAGE_TRANSFORMER.regExp.test("")).toBe(false); + expect( + URL_PREVIEW_IMAGE_TRANSFORMER.regExp.test( + "https://media.tenor.com/abc/tenor.gif" + ) + ).toBe(false); + expect( + URL_PREVIEW_IMAGE_TRANSFORMER.regExp.test("![Seize](https://x.com/a.png)") + ).toBe(false); + }); + + it("has a safe no-op replace handler", () => { + const children: any[] = [{ id: "node-1" }]; + const parentNode: any = { type: "paragraph" }; + const match = ["token"]; + + expect(() => + URL_PREVIEW_IMAGE_TRANSFORMER.replace?.(parentNode, children, match, true) + ).not.toThrow(); + expect(children).toHaveLength(1); + expect(children[0]).toEqual({ id: "node-1" }); + }); }); diff --git a/components/drops/create/lexical/nodes/ImageComponent.tsx b/components/drops/create/lexical/nodes/ImageComponent.tsx index 19f296de42..7eb362f56e 100644 --- a/components/drops/create/lexical/nodes/ImageComponent.tsx +++ b/components/drops/create/lexical/nodes/ImageComponent.tsx @@ -25,6 +25,8 @@ export default function ImageComponent({ }: ImageComponentProps): JSX.Element { const isUrlPreviewGif = altText === URL_PREVIEW_IMAGE_ALT_TEXT && isTenorGifUrl(src); + const displayAltText = + altText === URL_PREVIEW_IMAGE_ALT_TEXT ? "" : (altText ?? ""); const [dimensions, setDimensions] = useState({ width: width ?? 0, height: height ?? 0, @@ -58,7 +60,7 @@ export default function ImageComponent({ return ( {altText} import("./ImageComponent")); +const LOADING_IMAGE_SRC = "loading"; interface ImagePayload { key?: NodeKey | undefined; @@ -152,8 +154,17 @@ export class ImageNode extends DecoratorNode { } override decorate(): JSX.Element { + const fallback = + this.__src === LOADING_IMAGE_SRC ? ( +