'use client'; import React, { FC, useCallback, useEffect, useMemo, useRef, useState, ClipboardEvent, forwardRef, useImperativeHandle, } from 'react'; import clsx from 'clsx'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import EmojiPicker from 'emoji-picker-react'; import { Theme } from 'emoji-picker-react'; import { BoldText } from '@gitroom/frontend/components/new-launch/bold.text'; import { UText } from '@gitroom/frontend/components/new-launch/u.text'; import { SignatureBox } from '@gitroom/frontend/components/signature'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; import { useShallow } from 'zustand/react/shallow'; import { AddPostButton } from '@gitroom/frontend/components/new-launch/add.post.button'; import { MultiMediaComponent } from '@gitroom/frontend/components/media/media.component'; import { UpDownArrow } from '@gitroom/frontend/components/launches/up.down.arrow'; import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data'; import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core'; import { LinkedinCompanyPop } from '@gitroom/frontend/components/launches/helpers/linkedin.component'; import { useDropzone } from 'react-dropzone'; import { useUppyUploader } from '@gitroom/frontend/components/media/new.uploader'; import { Dashboard } from '@uppy/react'; import { useEditor, EditorContent, Extension, mergeAttributes, Node, } from '@tiptap/react'; import Document from '@tiptap/extension-document'; import Bold from '@tiptap/extension-bold'; import Text from '@tiptap/extension-text'; import Paragraph from '@tiptap/extension-paragraph'; import Underline from '@tiptap/extension-underline'; import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation'; import { History } from '@tiptap/extension-history'; import { BulletList, ListItem } from '@tiptap/extension-list'; import { Bullets } from '@gitroom/frontend/components/new-launch/bullets.component'; import Heading from '@tiptap/extension-heading'; import { HeadingComponent } from '@gitroom/frontend/components/new-launch/heading.component'; const InterceptBoldShortcut = Extension.create({ name: 'preventBoldWithUnderline', addKeyboardShortcuts() { return { 'Mod-b': () => { // For example, toggle bold while removing underline this.editor.commands.unsetUnderline(); return this.editor.commands.toggleBold(); }, }; }, }); const InterceptUnderlineShortcut = Extension.create({ name: 'preventUnderlineWithUnderline', addKeyboardShortcuts() { return { 'Mod-u': () => { // For example, toggle bold while removing underline this.editor.commands.unsetBold(); return this.editor.commands.toggleUnderline(); }, }; }, }); const Span = Node.create({ name: 'mention', inline: true, group: 'inline', selectable: false, atom: true, addAttributes() { return { linkedinId: { default: null, }, label: { default: '', }, }; }, parseHTML() { return [ { tag: 'span[data-linkedin-id]', }, ]; }, renderHTML({ HTMLAttributes }) { return [ 'span', mergeAttributes( // Exclude linkedinId from HTMLAttributes to avoid duplication Object.fromEntries( Object.entries(HTMLAttributes).filter(([key]) => key !== 'linkedinId') ), { 'data-linkedin-id': HTMLAttributes.linkedinId, class: 'mention', } ), `@${HTMLAttributes.label}`, ]; }, }); export const EditorWrapper: FC<{ totalPosts: number; value: string; }> = (props) => { const { setGlobalValueText, setInternalValueText, addRemoveInternal, internal, global, current, addInternalValue, addGlobalValue, setInternalValueMedia, appendInternalValueMedia, appendGlobalValueMedia, setGlobalValueMedia, changeOrderGlobal, changeOrderInternal, isCreateSet, deleteGlobalValue, deleteInternalValue, setGlobalValue, setInternalValue, internalFromAll, totalChars, postComment, dummy, editor, loadedState, setLoadedState, } = useLaunchStore( useShallow((state) => ({ internal: state.internal.find((p) => p.integration.id === state.current), internalFromAll: state.integrations.find((p) => p.id === state.current), global: state.global, current: state.current, addRemoveInternal: state.addRemoveInternal, dummy: state.dummy, setInternalValueText: state.setInternalValueText, setGlobalValueText: state.setGlobalValueText, addInternalValue: state.addInternalValue, addGlobalValue: state.addGlobalValue, setGlobalValueMedia: state.setGlobalValueMedia, setInternalValueMedia: state.setInternalValueMedia, changeOrderGlobal: state.changeOrderGlobal, changeOrderInternal: state.changeOrderInternal, isCreateSet: state.isCreateSet, deleteGlobalValue: state.deleteGlobalValue, deleteInternalValue: state.deleteInternalValue, setGlobalValue: state.setGlobalValue, setInternalValue: state.setInternalValue, totalChars: state.totalChars, appendInternalValueMedia: state.appendInternalValueMedia, appendGlobalValueMedia: state.appendGlobalValueMedia, postComment: state.postComment, editor: state.editor, loadedState: state.loaded, setLoadedState: state.setLoaded, })) ); const existingData = useExistingData(); const [loaded, setLoaded] = useState(true); useEffect(() => { if (loaded && loadedState) { return; } setLoadedState(true); setLoaded(true); }, [loaded, loadedState]); const canEdit = useMemo(() => { return current === 'global' || !!internal; }, [current, internal]); const items = useMemo(() => { if (internal) { return internal.integrationValue; } return global; }, [internal, global]); const setValue = useCallback( (value: string[]) => { const newValue = value.map((p, index) => { return { id: makeId(10), ...(items?.[index]?.media ? { media: items[index].media } : { media: [] }), content: p, }; }); if (internal) { return setInternalValue(current, newValue); } return setGlobalValue(newValue); }, [internal, items] ); useCopilotReadable({ description: 'Current content of posts', value: items.map((p) => p.content), }); useCopilotAction({ name: 'setPosts', description: 'a thread of posts', parameters: [ { name: 'content', type: 'string[]', description: 'a thread of posts', }, ], handler: async ({ content }) => { setValue(content); }, }); const changeValue = useCallback( (index: number) => (value: string) => { if (internal) { return setInternalValueText(current, index, value); } return setGlobalValueText(index, value); }, [current, global, internal] ); const changeImages = useCallback( (index: number) => (value: any[]) => { if (internal) { return setInternalValueMedia(current, index, value); } return setGlobalValueMedia(index, value); }, [current, global, internal] ); const appendImages = useCallback( (index: number) => (value: any[]) => { if (internal) { return appendInternalValueMedia(current, index, value); } return appendGlobalValueMedia(index, value); }, [current, global, internal] ); const changeOrder = useCallback( (index: number) => (direction: 'up' | 'down') => { if (internal) { changeOrderInternal(current, index, direction); return setLoaded(false); } changeOrderGlobal(index, direction); setLoaded(false); }, [changeOrderInternal, changeOrderGlobal, current, global, internal] ); const goBackToGlobal = useCallback(async () => { if ( await deleteDialog( 'This action is irreversible. Are you sure you want to go back to global mode?', 'Yes, go back to global mode' ) ) { setLoaded(false); addRemoveInternal(current); } }, [addRemoveInternal, current]); const addValue = useCallback( (index: number) => () => { if (internal) { return addInternalValue(index, current, [ { content: '', id: makeId(10), media: [], }, ]); } return addGlobalValue(index, [ { content: '', id: makeId(10), media: [], }, ]); }, [current, global, internal] ); const deletePost = useCallback( (index: number) => async () => { if ( !(await deleteDialog( 'Are you sure you want to delete this post?', 'Yes, delete it!' )) ) { return; } if (internal) { deleteInternalValue(current, index); return setLoaded(false); } deleteGlobalValue(index); setLoaded(false); }, [current, global, internal] ); if (!loaded || !loadedState) { return null; } return (
{items.map((g, index) => (
{!canEdit && !isCreateSet && (
{ if (index !== 0) { return; } setLoaded(false); addRemoveInternal(current); }} className="select-none cursor-pointer absolute w-full h-full left-0 top-0 bg-red-600/10 z-[100]" > {index === 0 && (
Edit
)}
)}
{index === 0 && current !== 'global' && canEdit && !existingData.integration && ( )} {items.length > 1 && ( )}
{canEdit ? ( ) : (
)}
))}
); }; export const Editor: FC<{ editorType?: 'normal' | 'markdown' | 'html'; totalPosts: number; value: string; num?: number; pictures?: any[]; allValues?: any[]; onChange: (value: string) => void; setImages?: (value: any[]) => void; appendImages?: (value: any[]) => void; autoComplete?: boolean; validateChars?: boolean; identifier?: string; totalChars?: number; dummy: boolean; }> = (props) => { const { editorType = 'normal', allValues, pictures, setImages, num, autoComplete, validateChars, identifier, appendImages, dummy, } = props; const user = useUser(); const [id] = useState(makeId(10)); const newRef = useRef(null); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const t = useT(); const editorRef = useRef(); const uppy = useUppyUploader({ onUploadSuccess: (result: any) => { appendImages(result); uppy.clear(); }, allowedFileTypes: 'image/*,video/mp4', }); const onDrop = useCallback( (acceptedFiles: File[]) => { for (const file of acceptedFiles) { uppy.addFile(file); } }, [uppy] ); const paste = useCallback( async (event: ClipboardEvent | File[]) => { // @ts-ignore const clipboardItems = event.clipboardData?.items; if (!clipboardItems) { return; } // @ts-ignore for (const item of clipboardItems) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) { uppy.addFile(file); } } } }, [uppy] ); const { getRootProps, isDragActive } = useDropzone({ onDrop }); const valueWithoutHtml = useMemo(() => { return stripHtmlValidation('normal', props.value || '', true); }, [props.value]); const addText = useCallback( (emoji: string) => { editorRef?.current?.editor.commands.insertContent(emoji); editorRef?.current?.editor.commands.focus(); }, [props.value, id] ); const addLinkedinTag = useCallback((text: string) => { const id = text.split('(')[1].split(')')[0]; const name = text.split('[')[1].split(']')[0]; editorRef?.current?.editor .chain() .focus() .insertContent({ type: 'mention', attrs: { linkedinId: id, label: name, }, }) .run(); }, []); return (
{(editorType === 'markdown' || editorType === 'html') && ( <> )}
setEmojiPickerOpen(!emojiPickerOpen)} > {'\uD83D\uDE00'}
{identifier === 'linkedin' || identifier === 'linkedin-page' ? ( ) : null}
{ addText(e.emoji); setEmojiPickerOpen(false); }} open={emojiPickerOpen} />
{validateChars && props.value.length === 0 && pictures?.length === 0 && (
Your post should have at least one character or one image.
)}
Drop your files here to upload
{ if (editorRef?.current?.editor?.isFocused) { return; } editorRef?.current?.editor?.commands?.focus('end'); }} >
{ if (editorRef?.current?.editor?.isFocused) { return; } editorRef?.current?.editor?.commands?.focus('end'); }} > {setImages && ( { setImages(value.target.value); }} onOpen={() => {}} onClose={() => {}} /> )}
{(props?.totalChars || 0) > 0 && (
props.totalChars && '!text-red-500' )} > {valueWithoutHtml.length}/{props.totalChars}
)}
); }; export const OnlyEditor = forwardRef< any, { editorType: 'normal' | 'markdown' | 'html'; value: string; onChange: (value: string) => void; paste?: (event: ClipboardEvent | File[]) => void; } >(({ editorType, value, onChange, paste }, ref) => { const editor = useEditor({ extensions: [ Document, Paragraph, Text, Underline, Bold, InterceptBoldShortcut, InterceptUnderlineShortcut, Span, BulletList, ListItem, Heading.configure({ levels: [1, 2, 3], }), History.configure({ depth: 100, // default is 100 newGroupDelay: 100, // default is 500ms }), ], content: value || '', shouldRerenderOnTransaction: true, immediatelyRender: false, // @ts-ignore onPaste: paste, onUpdate: (innerProps) => { onChange?.(innerProps.editor.getHTML()); }, }); useImperativeHandle(ref, () => ({ editor, })); return ; });