Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
galloppinggryphon opened this issue Aug 8, 2024 · 1 comment
Labels
bug Something isn't working slate

Comments

@galloppinggryphon
Copy link

galloppinggryphon commented Aug 8, 2024

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:

  • paragraph instead of p
  • heading along with level: n instead of h1, h2, etc
  • code instead of code_block

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
'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 ) => (
    <DropdownMenuPrimitive.SubTrigger
        className={cn(
            'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
            'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
            inset && 'pl-8',
            className,
        )}
        ref={ref}
        {...props}
    >
        {children}
        <Icons.chevronRight className="ml-auto size-4" />
    </DropdownMenuPrimitive.SubTrigger>
) )

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 ) => (
    <DropdownMenuPrimitive.Portal>
        <DropdownMenuContentVariants ref={ref} {...props} />
    </DropdownMenuPrimitive.Portal>
) )

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 ) => (
    <DropdownMenuPrimitive.CheckboxItem
        className={cn(
            'relative flex select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
            'cursor-pointer',
            className,
        )}
        ref={ref}
        {...props}
    >
        <span className="absolute left-2 flex size-3.5 items-center justify-center">
            <DropdownMenuPrimitive.ItemIndicator>
                <Icons.check className="size-4" />
            </DropdownMenuPrimitive.ItemIndicator>
        </span>
        {children}
    </DropdownMenuPrimitive.CheckboxItem>
) )

export const DropdownMenuRadioItem = withRef<
  typeof DropdownMenuPrimitive.RadioItem,
  {
    hideIcon?: boolean;
  }
>( ( { children, className, hideIcon, ...props }, ref ) => (
    <DropdownMenuPrimitive.RadioItem
        className={cn(
            'relative flex select-none items-center rounded-sm pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
            'h-9 cursor-pointer px-2 data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground',
            className,
        )}
        ref={ref}
        {...props}
    >
        {! hideIcon && (
            <span className="absolute right-2 flex size-3.5 items-center justify-center">
                <DropdownMenuPrimitive.ItemIndicator>
                    <Icons.check className="size-4" />
                </DropdownMenuPrimitive.ItemIndicator>
            </span>
        )}
        {children}
    </DropdownMenuPrimitive.RadioItem>
) )

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
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 (
        <DropdownMenu modal={false} {...openState} {...props}>
            <DropdownMenuTrigger asChild>
                <ToolbarButton
                    className="lg:min-w-[130px]"
                    isDropdown
                    pressed={openState.open}
                    tooltip="Turn into"
                >
                    <SelectedItemIcon className="size-5 lg:hidden" />
                    <span className="max-lg:hidden">{selectedItemLabel}</span>
                </ToolbarButton>
            </DropdownMenuTrigger>

            <DropdownMenuContent align="start" className="min-w-0">
                <DropdownMenuLabel>Turn into</DropdownMenuLabel>

                <DropdownMenuRadioGroup
                    className="flex flex-col gap-0.5"
                    onValueChange={( type ) => {
                        // 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 } ) => (
                        <DropdownMenuRadioItem
                            className="min-w-[180px]"
                            key={itemValue}
                            value={itemValue}
                        >
                            <Icon className="mr-2 size-5" />
                            {label}
                        </DropdownMenuRadioItem>
                    ) )}
                </DropdownMenuRadioGroup>
            </DropdownMenuContent>
        </DropdownMenu>
    )
}

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
import {
    createPluginFactory,
    EElement,
} from '@udecode/plate-common'

export const KEY_DESERIALIZE_SLATE = 'slateDeserializer'

function toPlateJson( data: EElement<any>[] ) {
    return data.map( ( element: EElement<any> ) => {
        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

  • You can sponsor this specific effort via a Polar.sh pledge below
  • We receive the pledge once the issue is completed & verified
Fund with Polar
@galloppinggryphon galloppinggryphon added the bug Something isn't working label Aug 8, 2024
@12joan 12joan added the slate label Aug 9, 2024
@12joan
Copy link
Collaborator

12joan commented Aug 9, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working slate
Projects
None yet
Development

No branches or pull requests

2 participants