diff --git a/apps/web/src/components/user/profile-experimental.tsx b/apps/web/src/components/user/profile-experimental.tsx index 275885d0ee..fac14a5742 100644 --- a/apps/web/src/components/user/profile-experimental.tsx +++ b/apps/web/src/components/user/profile-experimental.tsx @@ -11,6 +11,12 @@ export const features = { activeInDev: true, hideInProduction: true, }, + editorPluginCopyTool: { + cookieName: 'useEditorPluginCopyTool', + isActive: false, + activeInDev: true, + hideInProduction: true, + }, editorAnchorLinkCopyTool: { cookieName: 'useEditorAnchorLinkCopyTool', isActive: false, diff --git a/apps/web/src/data/en/index.ts b/apps/web/src/data/en/index.ts index 8266b558f6..1c12f21be7 100644 --- a/apps/web/src/data/en/index.ts +++ b/apps/web/src/data/en/index.ts @@ -836,6 +836,8 @@ export const loggedInData = { link: 'Link (%ctrlOrCmd% + K)', noElementPasteInLists: 'Sorry, pasting elements inside of lists is not allowed.', + pastingPluginNotAllowedHere: + 'Sorry, pasting this plugin here is not allowed.', linkOverlay: { placeholder: 'https://… or /1234', inputLabel: 'Paste or type a link', @@ -1084,6 +1086,8 @@ export const loggedInData = { ready: 'Ready to save?', anchorLinkWarning: 'This link will only work in the frontend and for content that has a somewhat new revision.', + pluginCopyInfo: 'You can now paste this plugin into text plugins', + pluginCopyButtonLabel: 'Copy plugin to clipboard', }, taxonomy: { title: 'Title', diff --git a/packages/editor/src/editor-ui/plugin-toolbar/plugin-tool-menu/plugin-copy-tool.tsx b/packages/editor/src/editor-ui/plugin-toolbar/plugin-tool-menu/plugin-copy-tool.tsx new file mode 100644 index 0000000000..6b6cfafb86 --- /dev/null +++ b/packages/editor/src/editor-ui/plugin-toolbar/plugin-tool-menu/plugin-copy-tool.tsx @@ -0,0 +1,47 @@ +import { selectStaticDocumentWithoutIds, store } from '@editor/store' +import { faCopy } from '@fortawesome/free-solid-svg-icons' +import { shouldUseFeature } from '@serlo/frontend/src/components/user/profile-experimental' +import { useInstanceData } from '@serlo/frontend/src/contexts/instance-context' +import { useEditorStrings } from '@serlo/frontend/src/contexts/logged-in-data-context' +import { showToastNotice } from '@serlo/frontend/src/helper/show-toast-notice' +import { useCallback } from 'react' + +import { DropdownButton } from './dropdown-button' + +interface PluginCopyToolProps { + pluginId: string +} +/** + * experimental plugin to copy plugin's editor state to the clipboard + */ +export function PluginCopyTool({ pluginId }: PluginCopyToolProps) { + const editorStrings = useEditorStrings() + const { strings } = useInstanceData() + + const handleOnClick = useCallback(() => { + const document = selectStaticDocumentWithoutIds(store.getState(), pluginId) + const rowsDocument = { plugin: 'rows', state: [document] } + + void navigator.clipboard.writeText(JSON.stringify(rowsDocument)) + showToastNotice(strings.share.copySuccess, 'success', 2000) + showToastNotice( + '👉 ' + editorStrings.edtrIo.pluginCopyInfo, + undefined, + 4000 + ) + }, [pluginId, strings, editorStrings]) + + if (!navigator.clipboard || !shouldUseFeature('editorPluginCopyTool')) { + return null + } + + return ( + + ) +} diff --git a/packages/editor/src/editor-ui/plugin-toolbar/plugin-tool-menu/plugin-default-tools.tsx b/packages/editor/src/editor-ui/plugin-toolbar/plugin-tool-menu/plugin-default-tools.tsx index d22d156aad..89c96c5836 100644 --- a/packages/editor/src/editor-ui/plugin-toolbar/plugin-tool-menu/plugin-default-tools.tsx +++ b/packages/editor/src/editor-ui/plugin-toolbar/plugin-tool-menu/plugin-default-tools.tsx @@ -14,6 +14,7 @@ import { useCallback, useContext } from 'react' import { AnchorLinkCopyTool } from './anchor-link-copy-tool' import { DropdownButton } from './dropdown-button' +import { PluginCopyTool } from './plugin-copy-tool' interface PluginDefaultToolsProps { pluginId: string @@ -82,6 +83,7 @@ export function PluginDefaultTools({ pluginId }: PluginDefaultToolsProps) { icon={faTrashAlt} dataQa="remove-plugin-button" /> + {serloEntityId ? ( ) : null} diff --git a/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx b/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx index 6b2d6e5b5c..60a236a1ea 100644 --- a/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx +++ b/packages/editor/src/plugins/text/hooks/use-editable-paste-handler.tsx @@ -1,17 +1,21 @@ import { isSelectionWithinList } from '@editor/editor-ui/plugin-toolbar/text-controls/utils/list' import { editorPlugins } from '@editor/plugin/helpers/editor-plugins' +import { checkIsAllowedNesting } from '@editor/plugins/rows/utils/check-is-allowed-nesting' import { selectDocument, selectMayManipulateSiblings, useAppDispatch, store, + selectAncestorPluginTypes, } from '@editor/store' +import type { EditorRowsDocument } from '@editor/types/editor-plugins' import { useEditorStrings } from '@serlo/frontend/src/contexts/logged-in-data-context' import { showToastNotice } from '@serlo/frontend/src/helper/show-toast-notice' import { useCallback } from 'react' import { Editor as SlateEditor } from 'slate' import { insertPlugin } from '../utils/insert-plugin' +import { shouldUseFeature } from '@/components/user/profile-experimental' interface UseEditablePasteHandlerArgs { editor: SlateEditor @@ -31,14 +35,43 @@ export const useEditablePasteHandler = (args: UseEditablePasteHandlerArgs) => { const text = event.clipboardData.getData('text') if (!files.length && !text) return - // Exit if unable to select document data or if not allowed to manipulate siblings + // Exit if unable to select document data const storeState = store.getState() const document = selectDocument(storeState, id) const mayManipulateSiblings = selectMayManipulateSiblings(storeState, id) - if (!document || !mayManipulateSiblings) return + if (!document) return + let media + + // pasting editor document string and insert as plugins + if ( + shouldUseFeature('editorPluginCopyTool') && + !media && + text.startsWith('{"plugin":"rows"') + ) { + const rowsDocument = JSON.parse(text) as EditorRowsDocument + if (rowsDocument.state.length !== 1) return + const pluginDocument = rowsDocument.state.at(0) + const typesOfAncestors = selectAncestorPluginTypes(store.getState(), id) + if (!pluginDocument || typesOfAncestors === null) return + + if ( + mayManipulateSiblings && + checkIsAllowedNesting(pluginDocument.plugin, typesOfAncestors) + ) { + media = { + pluginType: pluginDocument.plugin, + state: pluginDocument.state, + } + } else { + event.preventDefault() + showToastNotice(textStrings.pastingPluginNotAllowedHere, 'warning') + } + } + + // Exit if not allowed to manipulate siblings + if (!mayManipulateSiblings) return // Iterate through all plugins and try to process clipboard data - let media for (const { plugin, type } of editorPlugins.getAllWithData()) { const state = plugin.onFiles?.(files) ?? plugin.onText?.(text) if (state?.state) {