From a5163ffacbfc89fa0a66d5f0cb279c1d6ada0967 Mon Sep 17 00:00:00 2001 From: Marius Tobiassen Bungum Date: Thu, 5 Dec 2024 08:34:44 +0100 Subject: [PATCH 1/7] :sparkles: Added new image callbacks to RichText components to make it use images --- src/atoms/hooks/useAmplifyKit.ts | 10 ++-- src/atoms/hooks/useRichTextImage.ts | 23 ++++++++ src/atoms/utils/richtext.ts | 6 +-- .../RichTextDisplay/RichTextDisplay.tsx | 31 +++++++++-- .../RichTextEditor/EditorProvider.tsx | 52 +++++++++++++++++-- .../RichTextEditor/MenuBar/AddImageButton.tsx | 10 ++-- .../RichTextEditor/MenuBar/MenuBar.tsx | 6 +-- .../RichTextEditor/RichTextEditor.tsx | 27 ++++++++-- .../RichTextEditor/RichTextEditor.types.ts | 14 ++++- .../{ExtendedImage.ts => ExtendedImage.tsx} | 40 ++++++++++---- src/molecules/index.ts | 2 +- .../ReleasePosts/hooks/useTokenReleaseNote.ts | 3 ++ 12 files changed, 184 insertions(+), 40 deletions(-) create mode 100644 src/atoms/hooks/useRichTextImage.ts rename src/molecules/RichTextEditor/custom-extensions/{ExtendedImage.ts => ExtendedImage.tsx} (73%) diff --git a/src/atoms/hooks/useAmplifyKit.ts b/src/atoms/hooks/useAmplifyKit.ts index 6eda832c9..d28ea954b 100644 --- a/src/atoms/hooks/useAmplifyKit.ts +++ b/src/atoms/hooks/useAmplifyKit.ts @@ -2,14 +2,13 @@ import { useMemo } from 'react'; import { RichText } from 'src/molecules/RichTextEditor'; import { - OnImageUploadFn, + ImageExtensionFnProps, RichTextEditorFeatures, } from 'src/molecules/RichTextEditor/RichTextEditor.types'; -interface AmplifyKitProps { +interface AmplifyKitProps extends ImageExtensionFnProps { features?: RichTextEditorFeatures[]; placeholder?: string; - onImageUpload?: OnImageUploadFn; } /* c8 ignore start */ @@ -17,6 +16,7 @@ export const useAmplifyKit = ({ features, placeholder, onImageUpload, + onImageRead, }: AmplifyKitProps) => { // This hooks is where we can use the features API we made to turn off extensions in the new configure API // Currently its only turning off the image extension since its the only one that needs to be turned off for the moment @@ -40,13 +40,13 @@ export const useAmplifyKit = ({ ? {} : false, image: features?.includes(RichTextEditorFeatures.IMAGES) - ? { onImageUpload } + ? { onImageUpload, onImageRead } : false, heading: features?.includes(RichTextEditorFeatures.HEADERS) ? {} : false, }), - [onImageUpload, placeholder, features] + [placeholder, features, onImageUpload, onImageRead] ); }; /* c8 ignore stop */ diff --git a/src/atoms/hooks/useRichTextImage.ts b/src/atoms/hooks/useRichTextImage.ts new file mode 100644 index 000000000..16a7a0bc6 --- /dev/null +++ b/src/atoms/hooks/useRichTextImage.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; + +import { ImageExtensionFnProps } from 'src/molecules/RichTextEditor/RichTextEditor.types'; + +export function useRichTextImage( + image: string, + onImageRead: ImageExtensionFnProps['onImageRead'] | undefined +) { + return useQuery({ + queryKey: ['image', image], + queryFn: async () => { + if (onImageRead) { + const response = await onImageRead(image); + const extension = image.split('.').at(-1); + return `data:image/${extension};base64,${response}`; + } + + return image; + }, + staleTime: Infinity, + gcTime: Infinity, + }); +} diff --git a/src/atoms/utils/richtext.ts b/src/atoms/utils/richtext.ts index c8ca0d642..1c4a502c2 100644 --- a/src/atoms/utils/richtext.ts +++ b/src/atoms/utils/richtext.ts @@ -1,14 +1,14 @@ import { DEFAULT_FEATURES, - OnImageUploadFn, + ImageExtensionFnProps, RichTextEditorFeatures, } from 'src/molecules/RichTextEditor/RichTextEditor.types'; -export interface FeaturesProps { +export interface FeaturesProps + extends Pick { features?: RichTextEditorFeatures[]; extendFeatures?: RichTextEditorFeatures[]; removeFeatures?: RichTextEditorFeatures[]; - onImageUpload?: OnImageUploadFn; } export function getFeatures({ diff --git a/src/molecules/RichTextDisplay/RichTextDisplay.tsx b/src/molecules/RichTextDisplay/RichTextDisplay.tsx index 536d4bafe..9347e4022 100644 --- a/src/molecules/RichTextDisplay/RichTextDisplay.tsx +++ b/src/molecules/RichTextDisplay/RichTextDisplay.tsx @@ -2,14 +2,22 @@ import { FC, HTMLAttributes, useEffect, useRef } from 'react'; import { Editor, Extensions, useEditor } from '@tiptap/react'; -import { AmplifyKit } from 'src/molecules/RichTextEditor/custom-extensions/AmplifyKit'; +import { useAmplifyKit } from 'src/atoms'; import { EditorContent, EditorStyling, } from 'src/molecules/RichTextEditor/RichTextEditor.styles'; +import { + DEFAULT_FEATURES, + ImageExtensionFnProps, +} from 'src/molecules/RichTextEditor/RichTextEditor.types'; -export interface RichTextDisplayProps { +export interface RichTextDisplayProps + extends Pick { value: string | null | undefined; + /** + * @deprecated - Use OnImageRead instead + */ imgReadToken?: string; lightBackground?: boolean; padding?: 'sm' | 'md' | 'lg' | 'none'; @@ -17,19 +25,34 @@ export interface RichTextDisplayProps { children?: (editor: Editor) => JSX.Element; } +/** + * + * @param value - Rich text content + * @param imgReadToken - Deprecated, use onImageRead instead + * @param onImageRead - handler used when loading images, expects b64 string to be returned + * @param lightBackground - if it should have a light BG color + * @param padding - padding in the editor + * @param extensions - additional extensions to be added + * @param children - render prop for custom rendering, provides editor via callback + */ export const RichTextDisplay: FC< RichTextDisplayProps & HTMLAttributes > = ({ value, imgReadToken, + onImageRead, lightBackground = true, padding = 'md', - extensions = [AmplifyKit], + extensions, children, ...rest }) => { + const defaultExtensions = useAmplifyKit({ + features: DEFAULT_FEATURES, + onImageRead: onImageRead, + }); const editor = useEditor({ - extensions: extensions, + extensions: extensions ? extensions : [defaultExtensions], content: imgReadToken ? value?.replaceAll(/()/g, `$1$2?${imgReadToken}$3`) : value, diff --git a/src/molecules/RichTextEditor/EditorProvider.tsx b/src/molecules/RichTextEditor/EditorProvider.tsx index a0f236905..d08092730 100644 --- a/src/molecules/RichTextEditor/EditorProvider.tsx +++ b/src/molecules/RichTextEditor/EditorProvider.tsx @@ -1,20 +1,19 @@ -import { FC, useEffect } from 'react'; +import { FC, useEffect, useRef } from 'react'; import { Editor, Extensions, useEditor } from '@tiptap/react'; import { - OnImageUploadFn, + ImageExtensionFnProps, RichTextEditorFeatures, } from './RichTextEditor.types'; import { useAmplifyKit } from 'src/atoms/hooks/useAmplifyKit'; -export interface EditorProviderProps { +export interface EditorProviderProps extends ImageExtensionFnProps { children: (editor: Editor) => JSX.Element; content: string | null | undefined; extensions?: Extensions; onUpdate?: (html: string) => void; placeholder?: string; - onImageUpload?: OnImageUploadFn; features?: RichTextEditorFeatures[]; } @@ -25,6 +24,8 @@ export const EditorProvider: FC = ({ placeholder, onUpdate, onImageUpload, + onImageRead, + onRemovedImagesChange, extensions = [], }) => { // Lets us apply the features API with the new amplify kit @@ -32,12 +33,53 @@ export const EditorProvider: FC = ({ features, placeholder, onImageUpload, + onImageRead, }); + const addedImages = useRef([]); + const previousRemovedImages = useRef([]); + + const handleImageCheck = (editor: Editor) => { + const currentImages: string[] = []; + + editor.getJSON().content?.forEach((item) => { + if (item.type === 'image' && item.attrs?.src) { + currentImages.push(item.attrs.src); + } + }); + + for (const image of currentImages) { + if (!addedImages.current.includes(image)) { + addedImages.current.push(image); + } + } + + const removedImages = addedImages.current.filter( + (image) => !currentImages.includes(image) + ); + if ( + previousRemovedImages.current.some( + (image) => !removedImages.includes(image) + ) || + removedImages.some( + (image) => !previousRemovedImages.current.includes(image) + ) + ) { + onRemovedImagesChange?.(removedImages); + previousRemovedImages.current = removedImages; + } + }; const editor = useEditor({ content, extensions: [ampExtensions, ...extensions], - onUpdate: ({ editor }) => onUpdate?.(editor.getHTML()), + onCreate: ({ editor }) => { + handleImageCheck(editor); + }, + onUpdate: ({ editor }) => { + handleImageCheck(editor); + + onUpdate?.(editor.getHTML()); + }, }); /* c8 ignore start */ diff --git a/src/molecules/RichTextEditor/MenuBar/AddImageButton.tsx b/src/molecules/RichTextEditor/MenuBar/AddImageButton.tsx index 0b8b5f77a..bb5111a13 100644 --- a/src/molecules/RichTextEditor/MenuBar/AddImageButton.tsx +++ b/src/molecules/RichTextEditor/MenuBar/AddImageButton.tsx @@ -5,13 +5,13 @@ import { camera_add_photo } from '@equinor/eds-icons'; import { EditorMenu } from './MenuBar'; import { EditorPanel, - OnImageUploadFn, + ImageExtensionFnProps, RichTextEditorFeatures, } from 'src/molecules/RichTextEditor/RichTextEditor.types'; -export interface AddImageProps extends EditorPanel { - onImageUpload?: OnImageUploadFn; -} +export interface AddImageProps + extends EditorPanel, + Pick {} export const AddImageButton: FC = ({ onImageUpload, @@ -42,7 +42,7 @@ export const AddImageButton: FC = ({ const image = await onImageUpload(files[0]); if (!image) return; - editor?.chain().focus().setImage({ src: image.b64, alt: image.url }).run(); + editor?.chain().focus().setImage({ src: image.src, alt: image.alt }).run(); }; /* c8 ignore end */ diff --git a/src/molecules/RichTextEditor/MenuBar/MenuBar.tsx b/src/molecules/RichTextEditor/MenuBar/MenuBar.tsx index 108ef774b..af7059679 100644 --- a/src/molecules/RichTextEditor/MenuBar/MenuBar.tsx +++ b/src/molecules/RichTextEditor/MenuBar/MenuBar.tsx @@ -3,7 +3,7 @@ import { FC } from 'react'; import { Editor } from '@tiptap/react'; import { - OnImageUploadFn, + ImageExtensionFnProps, RichTextEditorFeatures, } from '../RichTextEditor.types'; import { TextTable } from './Table/Table'; @@ -37,10 +37,10 @@ const MenuBar = styled.div` border-bottom: 1px solid ${colors.ui.background__medium.rgba}; `; -export interface MenuBarProps { +export interface MenuBarProps + extends Pick { editor: Editor; features: RichTextEditorFeatures[]; - onImageUpload?: OnImageUploadFn; } /* c8 ignore start */ diff --git a/src/molecules/RichTextEditor/RichTextEditor.tsx b/src/molecules/RichTextEditor/RichTextEditor.tsx index 4ddbd9032..4191de904 100644 --- a/src/molecules/RichTextEditor/RichTextEditor.tsx +++ b/src/molecules/RichTextEditor/RichTextEditor.tsx @@ -4,15 +4,14 @@ import { AmplifyBar } from './MenuBar/MenuBar'; import { EditorProvider } from './EditorProvider'; import { EditorContent, EditorStyling } from './RichTextEditor.styles'; import { - OnImageUploadFn, + ImageExtensionFnProps, RichTextEditorFeatures, } from './RichTextEditor.types'; import { getFeatures } from 'src/atoms/utils/richtext'; -export interface RichTextEditorProps { +export interface RichTextEditorProps extends ImageExtensionFnProps { value: string | null | undefined; onChange: (value: string) => void; - onImageUpload?: OnImageUploadFn; placeholder?: string; features?: RichTextEditorFeatures[]; extendFeatures?: RichTextEditorFeatures[]; @@ -25,10 +24,30 @@ export interface RichTextEditorProps { highlighted?: boolean; } +/** + * + * @param value - Rich text content + * @param onChange - handler for when the content changes + * @param onImageUpload - handler for when an image is uploaded + * @param onImageRead - handler used when loading images, expects b64 string to be returned + * @param onRemovedImagesChange - called when the list of removed images change + * @param placeholder - placeholder text if there is no content + * @param features - which features should be enabled + * @param extendFeatures - additional features to be added + * @param removeFeatures - features to exclude from default features + * @param padding - padding in the editor + * @param maxHeight - maxHeight of the text box + * @param minHeight - minHeight of the text box + * @param lightBackground - if it should have a different BG color + * @param border - if it should have a border + * @param highlighted - if it should have a highlighted border + */ export const RichTextEditor: FC = ({ value, onChange, onImageUpload, + onImageRead, + onRemovedImagesChange, placeholder, features, extendFeatures, @@ -53,6 +72,8 @@ export const RichTextEditor: FC = ({ features={usedFeatured} placeholder={placeholder} onImageUpload={onImageUpload} + onImageRead={onImageRead} + onRemovedImagesChange={onRemovedImagesChange} > {(editor) => ( Promise<{ b64: string; url: string } | undefined>; +) => Promise<{ src: string; alt: string } | undefined>; + +type OnImageReadFn = (src: string) => Promise; + +type OnRemovedImagesChange = (images: string[]) => void; + +export interface ImageExtensionFnProps { + onImageUpload?: OnImageUploadFn; + onImageRead?: OnImageReadFn; + onRemovedImagesChange?: OnRemovedImagesChange; +} export interface EditorPanel { editor: Editor; diff --git a/src/molecules/RichTextEditor/custom-extensions/ExtendedImage.ts b/src/molecules/RichTextEditor/custom-extensions/ExtendedImage.tsx similarity index 73% rename from src/molecules/RichTextEditor/custom-extensions/ExtendedImage.ts rename to src/molecules/RichTextEditor/custom-extensions/ExtendedImage.tsx index a6f455a78..d77332dff 100644 --- a/src/molecules/RichTextEditor/custom-extensions/ExtendedImage.ts +++ b/src/molecules/RichTextEditor/custom-extensions/ExtendedImage.tsx @@ -1,30 +1,52 @@ import Image from '@tiptap/extension-image'; import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { + NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, +} from '@tiptap/react'; -import { OnImageUploadFn } from 'src/molecules/RichTextEditor/RichTextEditor.types'; +import { useRichTextImage } from 'src/atoms/hooks/useRichTextImage'; +import { ImageExtensionFnProps } from 'src/molecules/RichTextEditor/RichTextEditor.types'; -export interface ExtendedImageOptions { +export interface ExtendedImageOptions extends ImageExtensionFnProps { inline: boolean; allowBase64: boolean; - onImageUpload?: OnImageUploadFn; } declare module '@tiptap/extension-image' { - interface ImageOptions { + interface ImageOptions extends ImageExtensionFnProps { inline: boolean; allowBase64: boolean; - onImageUpload?: OnImageUploadFn; } } +const Component = (props: NodeViewProps) => { + const { src, alt } = props.node.attrs; + const onImageRead: ImageExtensionFnProps['onImageRead'] | undefined = + props.extension.options.onImageRead; + const { data: usingSrc } = useRichTextImage(src, onImageRead); + + return ( + + {alt} + + ); +}; + +// TODO: Add 'onDelete' handler so it's easier to remove images in the apps /* c8 ignore start */ export default Image.extend({ addOptions() { return { ...this.parent?.(), onImageUpload: undefined, + onImageRead: undefined, }; }, + addNodeView() { + return ReactNodeViewRenderer(Component); + }, addProseMirrorPlugins() { const { onImageUpload } = this.options; @@ -63,8 +85,8 @@ export default Image.extend({ .then((item) => { if (!item) return; const node = schema.nodes.image.create({ - src: item.b64, - alt: item.url, + src: item.src, + alt: item.alt, }); const transaction = view.state.tr.insert( coordinates.pos, @@ -101,8 +123,8 @@ export default Image.extend({ .then((item) => { if (!item) return; const node = schema.nodes.image.create({ - src: item.b64, - alt: item.url, + src: item.src, + alt: item.src, }); const transaction = view.state.tr.replaceSelectionWith(node); diff --git a/src/molecules/index.ts b/src/molecules/index.ts index 7ae04907e..fb0d87e40 100644 --- a/src/molecules/index.ts +++ b/src/molecules/index.ts @@ -50,7 +50,7 @@ export { } from './RichTextEditor/MenuBar/Table/TableBar'; export { DEFAULT_FEATURES } from './RichTextEditor/RichTextEditor.types'; export { RichTextEditorFeatures } from './RichTextEditor/RichTextEditor.types'; -export type { OnImageUploadFn } from './RichTextEditor/RichTextEditor.types'; +export type * from './RichTextEditor/RichTextEditor.types'; export { Search } from './Search/Search'; export { Sieve } from './Sieve/Sieve'; export type { diff --git a/src/organisms/TopBar/Resources/ReleaseNotesDialog/ReleasePosts/hooks/useTokenReleaseNote.ts b/src/organisms/TopBar/Resources/ReleaseNotesDialog/ReleasePosts/hooks/useTokenReleaseNote.ts index df960ebf5..ba0394581 100644 --- a/src/organisms/TopBar/Resources/ReleaseNotesDialog/ReleasePosts/hooks/useTokenReleaseNote.ts +++ b/src/organisms/TopBar/Resources/ReleaseNotesDialog/ReleasePosts/hooks/useTokenReleaseNote.ts @@ -1,6 +1,9 @@ import { ReleaseNotesService } from '@equinor/subsurface-app-management'; import { useQuery } from '@tanstack/react-query'; +/** + * @deprecated - This will change once SAM's new release note endpoints are implemented + */ export function useTokenReleaseNote() { return useQuery({ queryKey: ['get-token-release-note'], From 4b8353ab06795404eea0e4980655a5e1e0cc4555 Mon Sep 17 00:00:00 2001 From: Marius Tobiassen Bungum Date: Thu, 5 Dec 2024 08:45:32 +0100 Subject: [PATCH 2/7] :wastebasket: Deprecate old richtext utils related to images --- src/atoms/utils/richtext.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/atoms/utils/richtext.ts b/src/atoms/utils/richtext.ts index 1c4a502c2..caaf2c2d7 100644 --- a/src/atoms/utils/richtext.ts +++ b/src/atoms/utils/richtext.ts @@ -35,9 +35,18 @@ export function getFeatures({ return usedFeatures; } +/** + * @deprecated - Use the onImageRead prop instead of tokens + */ export const IMG_WITH_SRC_AND_ALT = /((.*?))/g; +/** + * @deprecated - Use the onImageRead prop instead of tokens + */ export const IMG_WITH_ALT = /((.*?))/g; +/** + * @deprecated - Use the onImageRead prop instead of tokens + */ export function extractImageUrls(value: string | undefined): string[] { if (!value) return []; @@ -50,6 +59,9 @@ export function extractImageUrls(value: string | undefined): string[] { return images; } +/** + * @deprecated - Use the onImageRead prop instead of tokens + */ export function imageToB64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -61,6 +73,9 @@ export function imageToB64(file: File): Promise { }); } +/** + * @deprecated - Use the onImageRead prop instead of tokens + */ export function cleanRichTextValue(value: string) { return value.replaceAll(IMG_WITH_SRC_AND_ALT, `$2`); } From 6435ad52dd1f8f56eefd1a1befdfb81dee35919b Mon Sep 17 00:00:00 2001 From: Marius Tobiassen Bungum Date: Thu, 5 Dec 2024 09:00:38 +0100 Subject: [PATCH 3/7] :white_check_mark: Added test for image read fn --- .../RichTextDisplay/RichTextDisplay.test.tsx | 26 +++++++++++++++++-- .../RichTextEditor/EditorProvider.tsx | 3 +++ .../RichTextEditor/RichTextEditor.test.tsx | 9 +++++-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/molecules/RichTextDisplay/RichTextDisplay.test.tsx b/src/molecules/RichTextDisplay/RichTextDisplay.test.tsx index 740394123..6cac46cbd 100644 --- a/src/molecules/RichTextDisplay/RichTextDisplay.test.tsx +++ b/src/molecules/RichTextDisplay/RichTextDisplay.test.tsx @@ -2,7 +2,9 @@ import { tokens } from '@equinor/eds-tokens'; import { faker } from '@faker-js/faker'; import { RichTextDisplay } from 'src/molecules/RichTextDisplay/RichTextDisplay'; -import { act, render, screen } from 'src/tests/test-utils'; +import { act, render, renderWithProviders, screen } from 'src/tests/test-utils'; + +import { expect } from 'vitest'; const { spacings } = tokens; @@ -23,7 +25,7 @@ test('Image read token prop works as expected', async () => { 'https://images.unsplash.com/photo-1682687221363-72518513620e'; const randomToken = 'sv=2023-08-03'; - render( + renderWithProviders( `} imgReadToken={randomToken} @@ -41,6 +43,26 @@ test('Image read token prop works as expected', async () => { ); }); +test('onImageRead fn works as expected', async () => { + const randomUrl = + 'https://images.unsplash.com/photo-1682687221363-72518513620e'; + const handleImageRead = vi.fn(); + + renderWithProviders( + `} + onImageRead={handleImageRead} + /> + ); + + // Wait for tip tap to initialize + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + expect(handleImageRead).toHaveBeenCalledWith(randomUrl); +}); + test('Shows content when sending in new value', async () => { const content = faker.animal.dog(); const { rerender } = render(); diff --git a/src/molecules/RichTextEditor/EditorProvider.tsx b/src/molecules/RichTextEditor/EditorProvider.tsx index d08092730..30b372e6e 100644 --- a/src/molecules/RichTextEditor/EditorProvider.tsx +++ b/src/molecules/RichTextEditor/EditorProvider.tsx @@ -53,6 +53,8 @@ export const EditorProvider: FC = ({ } } + // TODO: Test this when we move to browser mode + /* c8 ignore start */ const removedImages = addedImages.current.filter( (image) => !currentImages.includes(image) ); @@ -67,6 +69,7 @@ export const EditorProvider: FC = ({ onRemovedImagesChange?.(removedImages); previousRemovedImages.current = removedImages; } + /* c8 ignore end */ }; const editor = useEditor({ diff --git a/src/molecules/RichTextEditor/RichTextEditor.test.tsx b/src/molecules/RichTextEditor/RichTextEditor.test.tsx index 335383766..86325bdbc 100644 --- a/src/molecules/RichTextEditor/RichTextEditor.test.tsx +++ b/src/molecules/RichTextEditor/RichTextEditor.test.tsx @@ -6,7 +6,12 @@ import { RichTextEditor, RichTextEditorProps } from './RichTextEditor'; import { RichTextEditorFeatures } from './RichTextEditor.types'; import { colors } from 'src/atoms'; import type { AmplifyKitOptions } from 'src/molecules/RichTextEditor/custom-extensions/AmplifyKit'; -import { render, screen, userEvent } from 'src/tests/test-utils'; +import { + render, + renderWithProviders, + screen, + userEvent, +} from 'src/tests/test-utils'; function fakeProps(withImage = false): RichTextEditorProps { return { @@ -162,7 +167,7 @@ test('Images work as expected', async () => { const alt = faker.animal.crocodilia(); const props = fakeProps(true); - render( + renderWithProviders( `} From a6eed3826bb90f416846b03b97656ea2d8d7ebc9 Mon Sep 17 00:00:00 2001 From: Marius Tobiassen Bungum Date: Thu, 5 Dec 2024 12:10:13 +0100 Subject: [PATCH 4/7] :recycle: Changed how we handle deleting images in RichText --- src/atoms/hooks/useRichTextImage.ts | 2 +- .../RichTextEditor/EditorProvider.tsx | 49 ++++++++++++++----- .../RichTextEditor/RichTextEditor.tsx | 3 ++ .../RichTextEditor/RichTextEditor.types.ts | 10 ++-- .../custom-extensions/ExtendedImage.tsx | 3 +- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/atoms/hooks/useRichTextImage.ts b/src/atoms/hooks/useRichTextImage.ts index 16a7a0bc6..983771748 100644 --- a/src/atoms/hooks/useRichTextImage.ts +++ b/src/atoms/hooks/useRichTextImage.ts @@ -7,7 +7,7 @@ export function useRichTextImage( onImageRead: ImageExtensionFnProps['onImageRead'] | undefined ) { return useQuery({ - queryKey: ['image', image], + queryKey: [image], queryFn: async () => { if (onImageRead) { const response = await onImageRead(image); diff --git a/src/molecules/RichTextEditor/EditorProvider.tsx b/src/molecules/RichTextEditor/EditorProvider.tsx index 30b372e6e..e0efd9d25 100644 --- a/src/molecules/RichTextEditor/EditorProvider.tsx +++ b/src/molecules/RichTextEditor/EditorProvider.tsx @@ -1,5 +1,6 @@ import { FC, useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { Editor, Extensions, useEditor } from '@tiptap/react'; import { @@ -25,6 +26,7 @@ export const EditorProvider: FC = ({ onUpdate, onImageUpload, onImageRead, + onImageRemove, onRemovedImagesChange, extensions = [], }) => { @@ -35,10 +37,14 @@ export const EditorProvider: FC = ({ onImageUpload, onImageRead, }); + const queryClient = useQueryClient(); const addedImages = useRef([]); + const deletedImages = useRef([]); const previousRemovedImages = useRef([]); const handleImageCheck = (editor: Editor) => { + // TODO: Test this when we move to browser mode + /* c8 ignore start */ const currentImages: string[] = []; editor.getJSON().content?.forEach((item) => { @@ -50,25 +56,44 @@ export const EditorProvider: FC = ({ for (const image of currentImages) { if (!addedImages.current.includes(image)) { addedImages.current.push(image); + } else if (addedImages.current.includes(image) && onImageRemove) { + // Image was recovered from undo, need to upload it again + const dataUrl = queryClient.getQueryData([image]); + if (dataUrl) { + const arr = dataUrl.split(','); + const mime = arr[0].match(/:(.*?);/)![1]; + const byteString = atob(arr[arr.length - 1]); + let n = byteString.length; + const u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = byteString.charCodeAt(n); + } + const file = new File([u8arr], image, { type: mime }); + onImageUpload?.(file); + } } } - // TODO: Test this when we move to browser mode - /* c8 ignore start */ - const removedImages = addedImages.current.filter( - (image) => !currentImages.includes(image) + const imagesToDelete = addedImages.current.filter( + (image) => + !currentImages.includes(image) && !deletedImages.current.includes(image) ); + + if (onImageRemove) { + for (const image of imagesToDelete) { + onImageRemove?.(image); + deletedImages.current.push(image); + } + } + if ( - previousRemovedImages.current.some( - (image) => !removedImages.includes(image) - ) || - removedImages.some( - (image) => !previousRemovedImages.current.includes(image) - ) + onRemovedImagesChange && + previousRemovedImages.current.length !== imagesToDelete.length ) { - onRemovedImagesChange?.(removedImages); - previousRemovedImages.current = removedImages; + onRemovedImagesChange(deletedImages.current); + previousRemovedImages.current = imagesToDelete; } + /* c8 ignore end */ }; diff --git a/src/molecules/RichTextEditor/RichTextEditor.tsx b/src/molecules/RichTextEditor/RichTextEditor.tsx index 4191de904..717ce13da 100644 --- a/src/molecules/RichTextEditor/RichTextEditor.tsx +++ b/src/molecules/RichTextEditor/RichTextEditor.tsx @@ -30,6 +30,7 @@ export interface RichTextEditorProps extends ImageExtensionFnProps { * @param onChange - handler for when the content changes * @param onImageUpload - handler for when an image is uploaded * @param onImageRead - handler used when loading images, expects b64 string to be returned + * @param onImageRemove - called when an image removed, use when deleting images instantly * @param onRemovedImagesChange - called when the list of removed images change * @param placeholder - placeholder text if there is no content * @param features - which features should be enabled @@ -47,6 +48,7 @@ export const RichTextEditor: FC = ({ onChange, onImageUpload, onImageRead, + onImageRemove, onRemovedImagesChange, placeholder, features, @@ -73,6 +75,7 @@ export const RichTextEditor: FC = ({ placeholder={placeholder} onImageUpload={onImageUpload} onImageRead={onImageRead} + onImageRemove={onImageRemove} onRemovedImagesChange={onRemovedImagesChange} > {(editor) => ( diff --git a/src/molecules/RichTextEditor/RichTextEditor.types.ts b/src/molecules/RichTextEditor/RichTextEditor.types.ts index 3e05046a3..aab68da78 100644 --- a/src/molecules/RichTextEditor/RichTextEditor.types.ts +++ b/src/molecules/RichTextEditor/RichTextEditor.types.ts @@ -32,14 +32,12 @@ type OnImageUploadFn = ( file: File ) => Promise<{ src: string; alt: string } | undefined>; -type OnImageReadFn = (src: string) => Promise; - -type OnRemovedImagesChange = (images: string[]) => void; - +// TODO: Undo images calls onImageUpload again export interface ImageExtensionFnProps { onImageUpload?: OnImageUploadFn; - onImageRead?: OnImageReadFn; - onRemovedImagesChange?: OnRemovedImagesChange; + onImageRead?: (src: string) => Promise; + onImageRemove?: (src: string) => Promise; + onRemovedImagesChange?: (images: string[]) => void; } export interface EditorPanel { diff --git a/src/molecules/RichTextEditor/custom-extensions/ExtendedImage.tsx b/src/molecules/RichTextEditor/custom-extensions/ExtendedImage.tsx index d77332dff..96be56af9 100644 --- a/src/molecules/RichTextEditor/custom-extensions/ExtendedImage.tsx +++ b/src/molecules/RichTextEditor/custom-extensions/ExtendedImage.tsx @@ -17,7 +17,7 @@ export interface ExtendedImageOptions extends ImageExtensionFnProps { declare module '@tiptap/extension-image' { interface ImageOptions extends ImageExtensionFnProps { inline: boolean; - allowBase64: boolean; + allowBase64: boolean; // TODO: Try to set to false } } @@ -34,7 +34,6 @@ const Component = (props: NodeViewProps) => { ); }; -// TODO: Add 'onDelete' handler so it's easier to remove images in the apps /* c8 ignore start */ export default Image.extend({ addOptions() { From 9c1ab6fb634b2b216c372151b87598c9220f7c4d Mon Sep 17 00:00:00 2001 From: Marius Tobiassen Bungum Date: Thu, 5 Dec 2024 12:11:48 +0100 Subject: [PATCH 5/7] :children_crossing: Disallow using both remove strategies at the same time --- .../RichTextEditor/RichTextEditor.test.tsx | 15 +++++++++++++++ src/molecules/RichTextEditor/RichTextEditor.tsx | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/src/molecules/RichTextEditor/RichTextEditor.test.tsx b/src/molecules/RichTextEditor/RichTextEditor.test.tsx index 86325bdbc..eb5267719 100644 --- a/src/molecules/RichTextEditor/RichTextEditor.test.tsx +++ b/src/molecules/RichTextEditor/RichTextEditor.test.tsx @@ -182,6 +182,21 @@ test('Images work as expected', async () => { expect(screen.getByRole('img')).toHaveAttribute('alt', alt); }); +test('Throws error if trying to use both remove strategies', () => { + console.error = vi.fn(); + + const props = fakeProps(); + expect(() => + render( + + ) + ).toThrowError(); +}); + describe('Editor defaults can be merged', () => { const uniqe: Partial = { bold: { HTMLAttributes: { class: 'bolder' } }, diff --git a/src/molecules/RichTextEditor/RichTextEditor.tsx b/src/molecules/RichTextEditor/RichTextEditor.tsx index 717ce13da..24c4175c6 100644 --- a/src/molecules/RichTextEditor/RichTextEditor.tsx +++ b/src/molecules/RichTextEditor/RichTextEditor.tsx @@ -61,6 +61,12 @@ export const RichTextEditor: FC = ({ border = true, highlighted = false, }) => { + if (onImageRemove && onRemovedImagesChange) { + throw new Error( + 'onImageRemove and onRemovedImagesChange cannot be used together' + ); + } + const usedFeatured = getFeatures({ features, extendFeatures, From 2f16ec82f19c6c2d1259de7bd6f4d406ad091f70 Mon Sep 17 00:00:00 2001 From: Marius Tobiassen Bungum Date: Thu, 5 Dec 2024 12:17:27 +0100 Subject: [PATCH 6/7] :bug: Add EDS light tokens, so overriding to light theme works as expected --- src/atoms/style/lightTokens.ts | 76 ++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/atoms/style/lightTokens.ts b/src/atoms/style/lightTokens.ts index de392ddcf..6f5b57e67 100644 --- a/src/atoms/style/lightTokens.ts +++ b/src/atoms/style/lightTokens.ts @@ -53,5 +53,81 @@ export const lightTokens = css` --amplify_dataviz_lightgray_default: rgba(144, 155, 162, 1); --amplify_dataviz_lightgray_darker: rgba(91, 102, 108, 1); --amplify_dataviz_lightgray_lighter: rgba(194, 200, 204, 1); + + --eds_text__static_icons__default: rgba(61, 61, 61, 1); + --eds_text__static_icons__secondary: rgba(86, 86, 86, 1); + --eds_text__static_icons__tertiary: rgba(111, 111, 111, 1); + --eds_text__static_icons__primary_white: rgba(255, 255, 255, 1); + --eds_ui_background__default: rgba(255, 255, 255, 1); + --eds_ui_background__semitransparent: rgba(255, 255, 255, 0.2); + --eds_ui_background__light: rgba(247, 247, 247, 1); + --eds_ui_background__scrim: rgba(0, 0, 0, 0.4); + --eds_ui_background__overlay: rgba(0, 0, 0, 0.8); + --eds_ui_background__medium: rgba(220, 220, 220, 1); + --eds_ui_background__info: rgba(213, 234, 244, 1); + --eds_ui_background__warning: rgba(255, 231, 214, 1); + --eds_ui_background__danger: rgba(255, 193, 193, 1); + --eds_infographic_substitute__purple_berry: rgba(140, 17, 89, 1); + --eds_infographic_substitute__pink_rose: rgba(226, 73, 115, 1); + --eds_infographic_substitute__pink_salmon: rgba(255, 146, 168, 1); + --eds_infographic_substitute__green_cucumber: rgba(0, 95, 87, 1); + --eds_infographic_substitute__green_succulent: rgba(0, 151, 123, 1); + --eds_infographic_substitute__green_mint: rgba(64, 211, 143, 1); + --eds_infographic_substitute__blue_ocean: rgba(0, 64, 136, 1); + --eds_infographic_substitute__blue_overcast: rgba(0, 132, 196, 1); + --eds_infographic_substitute__blue_sky: rgba(82, 192, 255, 1); + --eds_infographic_primary__moss_green_100: rgba(0, 112, 121, 1); + --eds_infographic_primary__moss_green_55: rgba(115, 177, 181, 1); + --eds_infographic_primary__moss_green_34: rgba(168, 206, 209, 1); + --eds_infographic_primary__moss_green_21: rgba(201, 224, 226, 1); + --eds_infographic_primary__moss_green_13: rgba(222, 237, 238, 1); + --eds_infographic_primary__energy_red_100: rgba(235, 0, 55, 1); + --eds_infographic_primary__energy_red_55: rgba(255, 125, 152, 1); + --eds_infographic_primary__energy_red_34: rgba(255, 174, 191, 1); + --eds_infographic_primary__energy_red_21: rgba(255, 205, 215, 1); + --eds_infographic_primary__energy_red_13: rgba(255, 224, 231, 1); + --eds_infographic_primary__weathered_red: rgba(125, 0, 35, 1); + --eds_infographic_primary__slate_blue: rgba(36, 55, 70, 1); + --eds_infographic_primary__spruce_wood: rgba(255, 231, 214, 1); + --eds_infographic_primary__mist_blue: rgba(213, 234, 244, 1); + --eds_infographic_primary__lichen_green: rgba(230, 250, 236, 1); + --eds_logo__fill_positive: rgba(235, 0, 55, 1); + --eds_logo__fill_negative: rgba(255, 255, 255, 1); + --eds_interactive_primary__selected_highlight: rgba(230, 250, 236, 1); + --eds_interactive_primary__selected_hover: rgba(195, 243, 210, 1); + --eds_interactive_primary__resting: rgba(0, 112, 121, 1); + --eds_interactive_primary__hover: rgba(0, 79, 85, 1); + --eds_interactive_primary__hover_alt: rgba(222, 237, 238, 1); + --eds_interactive_secondary__highlight: rgba(213, 234, 244, 1); + --eds_interactive_secondary__resting: rgba(36, 55, 70, 1); + --eds_interactive_secondary__link_hover: rgba(23, 36, 47, 1); + --eds_interactive_danger__highlight: rgba(255, 193, 193, 1); + --eds_interactive_danger__resting: rgba(235, 0, 0, 1); + --eds_interactive_danger__hover: rgba(179, 13, 47, 1); + --eds_interactive_danger__text: rgba(179, 13, 47, 1); + --eds_interactive_warning__highlight: rgba(255, 231, 214, 1); + --eds_interactive_warning__resting: rgba(255, 146, 0, 1); + --eds_interactive_warning__hover: rgba(173, 98, 0, 1); + --eds_interactive_warning__text: rgba(173, 98, 0, 1); + --eds_interactive_success__highlight: rgba(230, 250, 236, 1); + --eds_interactive_success__resting: rgba(75, 183, 72, 1); + --eds_interactive_success__hover: rgba(53, 129, 50, 1); + --eds_interactive_success__text: rgba(53, 129, 50, 1); + --eds_interactive_table__cell__fill_resting: rgba(255, 255, 255, 1); + --eds_interactive_table__cell__fill_hover: rgba(234, 234, 234, 1); + --eds_interactive_table__cell__fill_activated: rgba(230, 250, 236, 1); + --eds_interactive_table__header__fill_activated: rgba(234, 234, 234, 1); + --eds_interactive_table__header__fill_hover: rgba(220, 220, 220, 1); + --eds_interactive_table__header__fill_resting: rgba(247, 247, 247, 1); + --eds_interactive__disabled__text: rgba(190, 190, 190, 1); + --eds_interactive__text_highlight: rgba(213, 234, 244, 1); + --eds_interactive__focus: rgba(0, 112, 121, 1); + --eds_interactive__disabled__border: rgba(220, 220, 220, 1); + --eds_interactive__disabled__fill: rgba(234, 234, 234, 1); + --eds_interactive__link_on_interactive_colors: rgba(255, 255, 255, 1); + --eds_interactive__icon_on_interactive_colors: rgba(255, 255, 255, 1); + --eds_interactive__link_in_snackbars: rgba(151, 202, 206, 1); + --eds_interactive__pressed_overlay_dark: rgba(0, 0, 0, 0.2); + --eds_interactive__pressed_overlay_light: rgba(255, 255, 255, 0.2); } `; From a2c7401eac8590479c76eaca5bacdb21570b9c90 Mon Sep 17 00:00:00 2001 From: Marius Tobiassen Bungum Date: Thu, 5 Dec 2024 12:55:08 +0100 Subject: [PATCH 7/7] :white_check_mark: Fix tests related to RichTextEditor now expecting a query provider --- .../RichTextDisplay/RichTextDisplay.tsx | 1 + .../RichTextEditor/RichTextEditor.test.tsx | 25 ++++++++----------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/molecules/RichTextDisplay/RichTextDisplay.tsx b/src/molecules/RichTextDisplay/RichTextDisplay.tsx index 9347e4022..5e72f2b2e 100644 --- a/src/molecules/RichTextDisplay/RichTextDisplay.tsx +++ b/src/molecules/RichTextDisplay/RichTextDisplay.tsx @@ -52,6 +52,7 @@ export const RichTextDisplay: FC< onImageRead: onImageRead, }); const editor = useEditor({ + /* c8 ignore next */ extensions: extensions ? extensions : [defaultExtensions], content: imgReadToken ? value?.replaceAll(/()/g, `$1$2?${imgReadToken}$3`) diff --git a/src/molecules/RichTextEditor/RichTextEditor.test.tsx b/src/molecules/RichTextEditor/RichTextEditor.test.tsx index eb5267719..97ad30ef3 100644 --- a/src/molecules/RichTextEditor/RichTextEditor.test.tsx +++ b/src/molecules/RichTextEditor/RichTextEditor.test.tsx @@ -6,12 +6,7 @@ import { RichTextEditor, RichTextEditorProps } from './RichTextEditor'; import { RichTextEditorFeatures } from './RichTextEditor.types'; import { colors } from 'src/atoms'; import type { AmplifyKitOptions } from 'src/molecules/RichTextEditor/custom-extensions/AmplifyKit'; -import { - render, - renderWithProviders, - screen, - userEvent, -} from 'src/tests/test-utils'; +import { renderWithProviders, screen, userEvent } from 'src/tests/test-utils'; function fakeProps(withImage = false): RichTextEditorProps { return { @@ -24,7 +19,7 @@ function fakeProps(withImage = false): RichTextEditorProps { test('Shows text that is input', async () => { const props = fakeProps(true); const fish = faker.animal.fish(); - render(${fish}

`} />); + renderWithProviders(${fish}

`} />); await waitFor(() => expect(screen.getByText(fish)).toBeInTheDocument(), { timeout: 1000, @@ -36,7 +31,7 @@ test('Throws error if providing RichTextEditorFeature.IMAGES but not an image ha const props = fakeProps(); expect(() => - render( + renderWithProviders( { const props = fakeProps(); - const { container } = render( + const { container } = renderWithProviders( { test('Setting color works as expected', async () => { const props = fakeProps(); - const { container } = render( + const { container } = renderWithProviders( ); @@ -89,7 +84,7 @@ test('Setting color works as expected', async () => { test('Calls onImageUpload as expected', async () => { const props = fakeProps(true); - const { container } = render( + const { container } = renderWithProviders( ); const user = userEvent.setup(); @@ -112,7 +107,7 @@ test('Calls onImageUpload as expected', async () => { test('Open file dialog', async () => { const props = fakeProps(true); - render( + renderWithProviders( ); const user = userEvent.setup(); @@ -127,7 +122,7 @@ test('Open file dialog', async () => { test('Creating table works as expected', async () => { const props = fakeProps(); - render( + renderWithProviders( ); const user = userEvent.setup(); @@ -144,7 +139,7 @@ test('Creating table works as expected', async () => { test('Creating table with highlight works as expected', async () => { const props = fakeProps(); - render( + renderWithProviders( { const props = fakeProps(); expect(() => - render( + renderWithProviders(