steven-tey / novel

Notion-style WYSIWYG editor with AI-powered autocompletion.
https://novel.sh
Apache License 2.0
13.03k stars 1.06k forks source link

Incorrect position of Drag Handle #234

Closed BleedingDev closed 7 months ago

BleedingDev commented 12 months ago

I have novel inside Dialog window (to be precise Shadcn Dialog) and Draggable handle has incorrect position when I hover something. It is below the content, which means I can't reach it by any means as it disappears before I can click it to move the box.

image

Here it is live: https://coursition.com/demo/edit - just add the TextEditor component and open the editor. :)

Expected outcome

Drag Handle is correctly positioned

Current outcome

Drag Handle can't be reached by any means

BleedingDev commented 11 months ago

So I found the cause, not sure how can I fix it.

The problem is that I show Novel inside the Dialog window from Shadcn, therefore the Drag & Drop handle is moved almost exactly by the position of the window. I replicated same behaviour in https://github.com/excalidraw/excalidraw, but there whenever I resize the window it fixes the bug, that is not the case inside Novel.

I am working on the example fix.

BleedingDev commented 11 months ago

Possible fix

I tried to fix this and I managed to do so with Novel!

Please take a look here (fixed with full size): https://edution-git-fix-mouse-position-in-dialog-pegak.vercel.app/demo/dialog/edit

And original (smaller dialog size): https://coursition.com/demo/dialog/edit

Steps to reproduce

  1. Add TextEditor component
  2. Open the editor
  3. See the behaviour of the mouse (Drag & Drop in Text Editor)
izakfilmalter commented 10 months ago

Running into the same issue. How did you fix it @BleedingDev?

izakfilmalter commented 10 months ago

I ended up modifying drag-and-drop as follows:

import { Extension } from '@tiptap/core'
import { NodeSelection, Plugin } from '@tiptap/pm/state'
// @ts-ignore
import { __serializeForClipboard, EditorView } from '@tiptap/pm/view'

import { isNotNil } from '@steepleinc/shared'

export interface DragHandleOptions {
  /**
   * The width of the drag handle
   */
  dragHandleWidth: number
}
function absoluteRect(node: Element) {
  const data = node.getBoundingClientRect()

  const modal = node.closest('[role="dialog"]')

  if (isNotNil(modal) && window.getComputedStyle(modal).transform !== 'none') {
    const modalRect = modal.getBoundingClientRect()

    return {
      top: data.top - modalRect.top,
      left: data.left - modalRect.left,
      width: data.width,
    }
  }

  return {
    top: data.top,
    left: data.left,
    width: data.width,
  }
}

function nodeDOMAtCoords(coords: { x: number; y: number }) {
  return document
    .elementsFromPoint(coords.x, coords.y)
    .find(
      (elem: Element) =>
        elem.parentElement?.matches?.('.ProseMirror') ||
        elem.matches(
          [
            'li',
            'p:not(:first-child)',
            'pre',
            'blockquote',
            'h1, h2, h3, h4, h5, h6',
          ].join(', '),
        ),
    )
}

function nodePosAtDOM(node: Element, view: EditorView) {
  const boundingRect = node.getBoundingClientRect()

  return view.posAtCoords({
    left: boundingRect.left + 1,
    top: boundingRect.top + 1,
  })?.inside
}

function DragHandle(options: DragHandleOptions) {
  function handleDragStart(event: DragEvent, view: EditorView) {
    view.focus()

    if (!event.dataTransfer) return

    const node = nodeDOMAtCoords({
      x: event.clientX + 50 + options.dragHandleWidth,
      y: event.clientY,
    })

    if (!(node instanceof Element)) return

    const nodePos = nodePosAtDOM(node, view)
    if (nodePos == null || nodePos < 0) return

    view.dispatch(
      view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)),
    )

    const slice = view.state.selection.content()
    const { dom, text } = __serializeForClipboard(view, slice)

    event.dataTransfer.clearData()
    event.dataTransfer.setData('text/html', dom.innerHTML)
    event.dataTransfer.setData('text/plain', text)
    event.dataTransfer.effectAllowed = 'copyMove'

    event.dataTransfer.setDragImage(node, 0, 0)

    view.dragging = { slice, move: event.ctrlKey }
  }

  function handleClick(event: MouseEvent, view: EditorView) {
    view.focus()

    view.dom.classList.remove('dragging')

    const node = nodeDOMAtCoords({
      x: event.clientX + 50 + options.dragHandleWidth,
      y: event.clientY,
    })

    if (!(node instanceof Element)) return

    const nodePos = nodePosAtDOM(node, view)
    if (!nodePos) return

    view.dispatch(
      view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)),
    )
  }

  let dragHandleElement: HTMLElement | null = null

  function hideDragHandle() {
    if (dragHandleElement) {
      dragHandleElement.classList.add('hidden')
    }
  }

  function showDragHandle() {
    if (dragHandleElement) {
      dragHandleElement.classList.remove('hidden')
    }
  }

  return new Plugin({
    view: (view) => {
      dragHandleElement = document.createElement('div')
      dragHandleElement.draggable = true
      dragHandleElement.dataset.dragHandle = ''
      dragHandleElement.classList.add('drag-handle')
      dragHandleElement.addEventListener('dragstart', (e) => {
        handleDragStart(e, view)
      })
      dragHandleElement.addEventListener('click', (e) => {
        handleClick(e, view)
      })

      hideDragHandle()

      view?.dom?.parentElement?.appendChild(dragHandleElement)

      return {
        destroy: () => {
          dragHandleElement?.remove?.()
          dragHandleElement = null
        },
      }
    },
    props: {
      handleDOMEvents: {
        mousemove: (view, event) => {
          if (!view.editable) {
            return
          }

          const node = nodeDOMAtCoords({
            x: event.clientX + 50 + options.dragHandleWidth,
            y: event.clientY,
          })

          if (!(node instanceof Element) || node.matches('ul, ol')) {
            hideDragHandle()
            return
          }

          const compStyle = window.getComputedStyle(node)
          const lineHeight = parseInt(compStyle.lineHeight, 10)
          const paddingTop = parseInt(compStyle.paddingTop, 10)

          const rect = absoluteRect(node)

          rect.top += (lineHeight - 24) / 2
          rect.top += paddingTop
          // Li markers
          if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
            rect.left -= options.dragHandleWidth
          }
          rect.width = options.dragHandleWidth

          if (!dragHandleElement) return

          dragHandleElement.style.left = `${rect.left - rect.width}px`
          dragHandleElement.style.top = `${rect.top}px`
          showDragHandle()
        },
        keydown: () => {
          hideDragHandle()
        },
        mousewheel: () => {
          hideDragHandle()
        },
        // dragging class is used for CSS
        dragstart: (view) => {
          view.dom.classList.add('dragging')
        },
        drop: (view) => {
          view.dom.classList.remove('dragging')
        },
        dragend: (view) => {
          view.dom.classList.remove('dragging')
        },
      },
    },
  })
}

interface DragAndDropOptions {}

export const DragAndDrop = Extension.create<DragAndDropOptions>({
  name: 'dragAndDrop',

  addProseMirrorPlugins() {
    return [
      DragHandle({
        dragHandleWidth: 24,
      }),
    ]
  },
})

They key code is where we find a parent dialog and then subtract out it's top and left value.

The problem is most dialogs have a transform on them which doesn't get accounted for in a child's getBoundingClientRect.