udecode / plate

Rich-text editor with shadcn
https://platejs.org
Other
10.32k stars 640 forks source link

Exception on copy-paste text between different Slate implementations #3421

Open galloppinggryphon opened 1 month ago

galloppinggryphon commented 1 month ago

Description

The problem

Copy-pasting content between incompatible implementations of Slate-based editors creates an exception, or put another way, it seems Plate cannot handle elements with an unfamiliar/unrecognized type attribute value.

Let's say you have a Plate editor and another Slate editor in another software package - in my case Keystone. These editors both use the Slate data format (application/x-slate-fragment), but element type is coded differently. For example:

And so on. While the pasting operation is successful (text appears unformatted), the type survived in the underlying AST. It's when you want to perform operations on these elements -- for example reformatting headers -- that you get a big nasty error:

Unhandled Runtime Error
Error: Rendered more hooks than during the previous render.
..\extensions\plate\plate-ui\dropdown-menu.tsx (171:21) @ _value

  169 | const onOpenChange = useCallback(
  170 |     ( _value = ! open ) => {
> 171 |         setOpen( _value )
      |                 ^
  172 |     },
  173 |     [ open ],
  174 | )

A screenshot of the error is attached. Evidently, the problem is in the DropdownMenu component (called by TurnIntoDropdownMenu) that I installed via the CLI. I've included the code for both. The Plate installation is close to vanilla, as I'm just getting familiar with Plate.

Code

Here's some incompatible markup (after pasting rich text into Plate) that will trigger an error:

[
    {
        "type": "heading",
        "children": [
            {
                "text": "Cow says moo"
            }
        ],
        "level": 3,
        "id": "dul38"
    },
]
dropdown-menu.tsx ```ts 'use client' import * as React from 'react' import { useCallback, useState } from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { cn, createPrimitiveElement, withCn, withProps, withRef, withVariants, } from '@udecode/cn' import { cva } from 'class-variance-authority' import { Icons } from '../components/icons' export const DropdownMenu = DropdownMenuPrimitive.Root export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger export const DropdownMenuGroup = DropdownMenuPrimitive.Group export const DropdownMenuPortal = DropdownMenuPrimitive.Portal export const DropdownMenuSub = DropdownMenuPrimitive.Sub export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup export const DropdownMenuSubTrigger = withRef< typeof DropdownMenuPrimitive.SubTrigger, { inset?: boolean; } >( ( { children, className, inset, ...props }, ref ) => ( {children} ) ) export const DropdownMenuSubContent = withCn( DropdownMenuPrimitive.SubContent, 'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', ) const DropdownMenuContentVariants = withProps( DropdownMenuPrimitive.Content, { className: cn( 'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', ), sideOffset: 4, } ) export const DropdownMenuContent = withRef< typeof DropdownMenuPrimitive.Content >( ( { ...props }, ref ) => ( ) ) const menuItemVariants = cva( cn( 'relative flex h-9 cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors', 'focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', ), { variants: { inset: { true: 'pl-8', }, }, }, ) export const DropdownMenuItem = withVariants( DropdownMenuPrimitive.Item, menuItemVariants, [ 'inset' ], ) export const DropdownMenuCheckboxItem = withRef< typeof DropdownMenuPrimitive.CheckboxItem >( ( { children, className, ...props }, ref ) => ( {children} ) ) export const DropdownMenuRadioItem = withRef< typeof DropdownMenuPrimitive.RadioItem, { hideIcon?: boolean; } >( ( { children, className, hideIcon, ...props }, ref ) => ( {! hideIcon && ( )} {children} ) ) const dropdownMenuLabelVariants = cva( cn( 'select-none px-2 py-1.5 text-sm font-semibold' ), { variants: { inset: { true: 'pl-8', }, }, }, ) export const DropdownMenuLabel = withVariants( DropdownMenuPrimitive.Label, dropdownMenuLabelVariants, [ 'inset' ], ) export const DropdownMenuSeparator = withCn( DropdownMenuPrimitive.Separator, '-mx-1 my-1 h-px bg-muted', ) export const DropdownMenuShortcut = withCn( createPrimitiveElement( 'span' ), 'ml-auto text-xs tracking-widest opacity-60', ) export const useOpenState = () => { const [ open, setOpen ] = useState( false ) const onOpenChange = useCallback( ( _value = ! open ) => { setOpen( _value ) }, [ open ], ) return { onOpenChange, open, } } ```
turn-into-dropdown-menu.tsx ```ts import React from 'react' import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu' import { ELEMENT_BLOCKQUOTE } from '@udecode/plate-block-quote' import { collapseSelection, focusEditor, getNodeEntries, isBlock, toggleNodeType, useEditorRef, useEditorSelector, } from '@udecode/plate-common' import { ELEMENT_H1, ELEMENT_H2, ELEMENT_H3 } from '@udecode/plate-heading' import { ELEMENT_PARAGRAPH } from '@udecode/plate-paragraph' import { Icons } from '../components/icons' import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, useOpenState, } from './dropdown-menu' import { ToolbarButton } from './toolbar' import { KEY_LIST_STYLE_TYPE } from '@udecode/plate-indent-list' import { unwrapList } from '@udecode/plate-list' const items = [ { description: 'Paragraph', icon: Icons.paragraph, label: 'Paragraph', value: ELEMENT_PARAGRAPH, }, { description: 'Heading 1', icon: Icons.h1, label: 'Heading 1', value: ELEMENT_H1, }, { description: 'Heading 2', icon: Icons.h2, label: 'Heading 2', value: ELEMENT_H2, }, { description: 'Heading 3', icon: Icons.h3, label: 'Heading 3', value: ELEMENT_H3, }, { description: 'Quote (⌘+⇧+.)', icon: Icons.blockquote, label: 'Quote', value: ELEMENT_BLOCKQUOTE, }, // { // value: 'ul', // label: 'Bulleted list', // description: 'Bulleted list', // icon: Icons.ul, // }, // { // value: 'ol', // label: 'Numbered list', // description: 'Numbered list', // icon: Icons.ol, // }, ] const defaultItem = items.find( ( item ) => item.value === ELEMENT_PARAGRAPH )! export function TurnIntoDropdownMenu( props: DropdownMenuProps ) { const value: string = useEditorSelector( ( editor ) => { let initialNodeType: string = ELEMENT_PARAGRAPH let allNodesMatchInitialNodeType = false const codeBlockEntries = getNodeEntries( editor, { match: ( n ) => isBlock( editor, n ), mode: 'highest', } ) const nodes = Array.from( codeBlockEntries ) if ( nodes.length > 0 ) { initialNodeType = nodes[ 0 ][ 0 ].type as string allNodesMatchInitialNodeType = nodes.every( ( [ node ] ) => { const type: string = ( node?.type as string ) || ELEMENT_PARAGRAPH return type === initialNodeType } ) } return allNodesMatchInitialNodeType ? initialNodeType : ELEMENT_PARAGRAPH }, [] ) const editor = useEditorRef() const openState = useOpenState() const selectedItem = items.find( ( item ) => item.value === value ) ?? defaultItem const { icon: SelectedItemIcon, label: selectedItemLabel } = selectedItem return ( {selectedItemLabel} Turn into { // if ( type === 'ul' || type === 'ol' ) { // if ( settingsStore.get.checkedId( KEY_LIST_STYLE_TYPE ) ) { // toggleIndentList( editor, { // listStyleType: type === 'ul' ? 'disc' : 'decimal', // } ) // } // else if ( settingsStore.get.checkedId( 'list' ) ) { // toggleList( editor, { type } ) // } // } // else { // unwrapList( editor ) toggleNodeType( editor, { activeType: type } ) // } collapseSelection( editor ) focusEditor( editor ) }} value={value} > {items.map( ( { icon: Icon, label, value: itemValue } ) => ( {label} ) )} ) } ```

Workaround

For my own purposes, I created a small plugin to convert Keystone Slate JSON to Plate JSON. This fixes the problem and also retains formatting.

slate-deserializer-plugin.ts ```ts import { createPluginFactory, EElement, } from '@udecode/plate-common' export const KEY_DESERIALIZE_SLATE = 'slateDeserializer' function toPlateJson( data: EElement[] ) { return data.map( ( element: EElement ) => { switch ( element.type ) { case 'code': { element.type = 'code_block' element.children.forEach( ( child ) => { child.type = 'code_line' } ) return element } case 'heading': { element.type = `h${ element.level}` delete element.level break } case 'paragraph': { element.type = 'p' break } } return element } ) } export const createSlateDeserializerPlugin = createPluginFactory( { key: KEY_DESERIALIZE_SLATE, then: () => ( { editor: { insertData: { format: 'application/x-slate-fragment', getFragment: ( { data } ) => { const decoded = decodeURIComponent( window.atob( data ) ) let parsed try { parsed = JSON.parse( decoded ) } catch ( error ) { /* empty */ } return toPlateJson( parsed ) }, }, }, } ), } ) ```

Reproduction URL

No response

Reproduction steps

1. Paste content from another (incompatible) Slate implementation
2. Attempt to switch the type of an element
3. Watch it throw an exception

Plate version

36.2.1

Slate React version

0.107.1

Screenshots

![React exception](https://github.com/user-attachments/assets/3af59cee-1663-409a-9a89-26c79730feae)

Logs

No response

Browsers

Firefox

Funding

Fund with Polar

12joan commented 1 month ago

This is an interesting issue.

I think in the general case, where manually handling conversions between different Slate editors isn't feasible, the best solution would be to prevent Slate fragments from being copied between incompatible editors in the first place.

In addition to the application/x-slate-fragment data type on the clipboard, Slate editors also encode the fragment in the text/html data using a data-slate-fragment HTML attribute on the first copied element.

withReact already has a clipboardFragmentKey option that can be used to customise application/x-slate-fragment, but there's currently no way of customising data-slate-fragment.

@galloppinggryphon Would you be able to make a PR on Slate such that the clipboardFragmentKey option is used for generating the HTML attribute name? You'll need to modify with-react.ts and dom.ts to refactor all hardcoded occurrences of data-slate-fragment.

To minimise the risk of breaking apps' custom logic, I think the defaults should remain the same. This might look like (simplified):

// Change the default from 'x-slate-fragment' to 'slate-fragment'
export const withReact = (editor: T, clipboardFormatKey = 'slate-fragment') => {
  const dataType = `application/x-${clipboardFormatKey}` // default 'application/x-slate-fragment'
  const attributeName = `data-${clipboardFormatKey}` // default 'data-slate-fragment'
  // ...
}

Once this logic is in place, we can modify packages/core/src/client/plugins/react/createReactPlugin.ts in Plate to make clipboardFormatKey customisable, perhaps through a convenient prop on the <Plate> component.