在contenteditable中实现精准的拖放

64

设置

我有一个可编辑的div -- 我正在制作所见即所得的编辑器:加粗,斜体,格式化等等,最近还可以插入带标题的花式图片。

<a class="fancy" href="i.jpg" target="_blank">
    <img alt="" src="i.jpg" />
    Optional Caption goes Here!
</a>

用户使用我提供的对话框添加这些花哨的图片:他们填写细节,上传图片,然后像其他编辑器功能一样,我使用document.execCommand('insertHTML',false,fancy_image_html);将其放置在用户选择的位置。
期望的功能
现在,我的用户可以放置一个花哨的图片 - 他们需要能够移动它。 用户需要能够单击并拖动图片(包括花式框)以将其放置在内容可编辑区域内任何位置。他们需要能够在段落之间移动它,甚至可以在段落内 - 在两个单词之间移动。
给我希望的是什么
请记住 - 在内容可编辑区域中,普通的<img>标签已经被用户代理赋予了这种可爱的拖放功能。默认情况下,您可以随意拖放<img>标签;默认拖放操作的行为就像梦想一样。

考虑到这种默认行为在我们的标签上已经非常成功,我只想稍微扩展一下这种行为,包括少量更多的HTML,这似乎是很容易实现的。

我迄今为止的努力

首先,我使用可拖动属性设置了我的高级标签,并禁用了contenteditable(不确定是否必要,但它看起来可能需要关闭):

<a class="fancy" [...] draggable="true" contenteditable="false">

然后,由于用户仍然可以将图像从花哨的<a>框中拖出来,所以我需要做一些CSS。我在Chrome中工作,因此我只向您展示了-webkit-前缀,尽管我也使用了其他前缀。

.fancy {
    -webkit-user-select:none;
    -webkit-user-drag:element; }
    .fancy>img {
        -webkit-user-drag:none; }

现在用户可以拖动整个花式框,而小部分淡化的点击拖动表示图像反映了这一点——我现在可以看到我正在拿起整个框:)
我尝试了几种不同CSS属性的组合,上面的组合对我来说似乎是有意义的,并且似乎效果最好。
我希望这个CSS就足以让浏览器自动将整个元素用作可拖动项,从而赋予用户我一直梦寐以求的功能……然而,情况似乎比那更复杂。
HTML5的JavaScript拖放API
这个拖放东西似乎比它需要的要复杂。
所以,我开始深入研究DnD api文档,现在我被卡住了。所以,这是我装备的(是的,jQuery):
$('.fancy')
    .bind('dragstart',function(event){
        //console.log('dragstart');
        var dt=event.originalEvent.dataTransfer;
        dt.effectAllowed = 'all';
        dt.setData('text/html',event.target.outerHTML);
    });

$('.myContentEditable')
    .bind('dragenter',function(event){
        //console.log('dragenter');
        event.preventDefault();
    })
    .bind('dragleave',function(event){
        //console.log('dragleave');
    })
    .bind('dragover',function(event){
        //console.log('dragover');
        event.preventDefault();
    })
    .bind('drop',function(event){
        //console.log('drop');      
        var dt = event.originalEvent.dataTransfer;
        var content = dt.getData('text/html');
        document.execCommand('insertHTML',false,content);
        event.preventDefault();
    })
    .bind('dragend',function(event){ 
        //console.log('dragend');
    });

这里是我的困境:这几乎完全可行。一切都运作良好,直到最后。在拖放事件中,我现在可以访问我试图插入到拖放位置的fancy box的HTML内容。现在我所需要做的就是将它插入正确的位置!问题是,我找不到正确的拖放位置,也没有任何方法可以将其插入其中。我一直希望能找到一些类似于“dropLocation”对象的东西来转储我的fancy box,例如dropEvent.dropLocation.content=myFancyBoxHTML;,或者至少有一些拖放位置值,以便自己找到将内容放置在那里的方法?我有什么吗?我是完全错误的吗?我完全错过了什么吗?我尝试使用document.execCommand('insertHTML',false,content);像我预期的那样,但不幸的是,选择插入符号并没有位于我希望的精确拖放位置。
我发现如果注释掉所有的event.preventDefault();,选择符号就会变得可见。当用户准备放置时,将拖动悬停在contenteditable上,小的选择符号可以看到,随着用户光标和拖放操作之间的字符移动,指示选择符号表示精确的放置位置。我需要这个选择符号的位置。 通过一些实验,我尝试在拖放事件和拖放结束事件中使用execCommand-insertHTML'ing,但都没有在放置选择符号的位置插入HTML,而是使用了拖动操作之前选择的任意位置。
因为选择符号在拖动过程中是可见的,所以我想出了一个计划。
有一段时间,我试图在拖动事件中插入一个临时标记,比如<span class="selection-marker">|</span>,紧接着$('.selection-marker').remove();,试图让浏览器不断(在拖动过程中)删除所有选择标记,并在插入点添加一个标记——从本质上讲,无论何时都在插入点留下一个标记。当然,我的计划是用我拥有的拖动内容替换这个临时标记。

当然,所有这些都没有起作用:我无法像计划的那样在显然可见的选取符插入选择标记——再次说明,execCommand-insertedHTML会放置在拖动操作之前的选择符位置。

哎呀,那我错过了什么? 该怎么做呢?

如何获取或插入拖放操作的精确位置?我觉得这显然是拖放操作中常见的操作之一——肯定有我忽略的重要且明显的细节吧?我是否需要深入JavaScript,或者也许有一种方法可以仅使用属性(例如draggable、droppable、contenteditable)和一些花里胡哨的CSS3来完成这项操作?

我还在寻找——还在尝试——一旦我发现自己失败的原因,我就会回复 :)


寻找继续(原帖后的编辑)

Farrukh提出了一个好建议--使用:

console.log( window.getSelection().getRangeAt(0) );

为了确定光标的实际位置,我将以下代码放入dragover事件中,因为在这个事件中,我认为选择光标在contenteditable中的可编辑内容之间可见地跳动。

然而,返回的Range对象报告的偏移索引属于拖放操作之前的选择光标。

这是一次英勇的尝试。谢谢Farrukh。

那么这里发生了什么?我有一种感觉,我看到的小光标不是选择光标!我觉得它是一个冒名顶替者!

进一步检查!

事实证明,它确实是一个冒牌货!在整个拖动操作期间,真正的选择光标保持不变!你可以看到这个小家伙!

我正在阅读MDN拖放文档,并发现了这个:

自然地,在 dragover 事件中,您可能需要移动插入标记。您可以使用事件的 clientX 和 clientY 属性来确定鼠标指针的位置,就像其他鼠标事件一样。哎呀,这是否意味着我应该根据 clientX 和 clientY 自己找出插入符号的位置?使用鼠标坐标自己确定选择符号的位置?太可怕了!除非我或者在这里阅读此内容的其他人能找到一个明智的解决方案,否则我将在明天研究一下。 :)

4
+1 很好的问题! - thordarson
当图像下降时,插入符号所在的位置就是您想要插入的位置。是吗?如果是这样,请使用此链接https://dev59.com/sHI-5IYBdhLWcg3wYXH8 - Farrukh Subhani
@FarrukhSubhani:这似乎是一个有前途的线索。我在 dragover 期间(这是选择插入符号可见的地方)使用 window.getSelection().getRangeAt(0); 进行了 console.log,它返回的 Range 对象显示了 拖放操作之前选择插入符号最初所在的索引值。不过还是谢谢你,这是个好想法。 - ChaseMoskal
4个回答

41

龙拖放

我已经做了很多琢磨。非常多的jsFiddling。

这不是一个强大或完整的解决方案;我可能永远不会想出一个完美的方法,如果有人有更好的解决方案,我虚心接受--我不想以这种方式解决它,但这是目前为止我能发现的唯一方法。以下jsFiddle和我即将吐出的信息在我的特定Firefox和Chrome版本以及特定WAMP设置和计算机上对我有效。 当它在你的网站上不起作用时,请不要向我抱怨。拖放这个玩意儿显然是每个人为自己负责。

jsFiddle:Chase Moskal的龙拖放

所以,我让女朋友无聊了,她以为我一直在说“龙拖放”,而实际上,我只是在说“拖放”。它挂了,所以这就是我称呼我创建的小JavaScript伙伴处理这些拖放情况的方式。

结果--它是一场噩梦。即使乍一看,HTML5拖放API也很糟糕。随后,您几乎会对它产生好感,因为您开始理解和接受它所应该的工作方式...然后您会意识到它实际上是多么可怕的噩梦,因为您了解Firefox和Chrome以它们自己特殊的方式来处理这个规范,并且似乎完全忽略了你的需求。您会发现自己问这样的问题:“等等,现在拖动哪个元素?我如何获取那些信息?我如何取消此拖动操作?我如何停止特定浏览器对此情况的独特默认处理?”...问题的答案是:“你自己想办法,笨蛋!不断地折腾,直到有用为止!”

所以,以下是我如何实现任意HTML元素的精确拖放,在多个contenteditable之间 (注意:我不会深入探讨每个细节,你需要查看jsFiddle - 我只是从经验中记住的看起来相关的细节涉及到,因为我时间有限)

我的解决方案

  • 首先,我对可拖动元素(fancybox)应用了CSS – 我们需要在fancy box上应用user-select:none; user-drag:element;,然后在图像内部(以及任何其他元素中),具体应用user-drag:none;。不幸的是,这对于Firefox来说还不够,它需要在图像上明确设置属性draggable="false"才能防止它被拖动。
  • 接下来,我在contenteditables上应用了属性draggable="true"dropzone="copy"

对于可拖动的元素(fancybox),我绑定了一个dragstart处理程序。我们将dataTransfer设置为复制空白HTML字符串'' -- 因为我们需要欺骗它,认为我们将要拖动HTML,但我们取消了任何默认行为。有时候默认行为会以某种方式悄悄地滑入,导致重复(因为我们自己进行插入),所以现在最糟糕的故障是当拖动失败时插入一个空格。我们不能依赖默认行为,因为它经常失败,所以我发现这是最通用的解决方案。

DD.$draggables.off('dragstart').on('dragstart',function(event){
    var e=event.originalEvent;
    $(e.target).removeAttr('dragged');
    var dt=e.dataTransfer,
        content=e.target.outerHTML;
    var is_draggable = DD.$draggables.is(e.target);
    if (is_draggable) {
        dt.effectAllowed = 'copy';
        dt.setData('text/plain',' ');
        DD.dropLoad=content;
        $(e.target).attr('dragged','dragged');
    }
});

对于拖放区域,我绑定了dragleavedrop的处理程序。 dragleave处理程序仅适用于Firefox,因为在Firefox中,当您尝试将其拖到contenteditable之外时,拖放功能会起作用(Chrome默认情况下会拒绝您),因此它会针对仅适用于Firefox的relatedTarget进行快速检查。嗯。

Chrome和Firefox获取Range对象的方式不同,因此在drop事件中必须分别为每个浏览器做出不同的努力。 Chrome基于鼠标坐标构建范围(是的,没错),但Firefox在事件数据中提供它。document.execCommand('insertHTML',false,blah)是我们处理拖放的方法。哦,我忘了提到-我们不能在Chrome上使用dataTransfer.getData()来获取我们的dragstart设置的HTML-这似乎是规范中某种奇怪的错误。 Firefox在规范上调用了bullcrap并仍然给了我们数据-但是Chrome没有,所以我们不得不倾斜全局内容,并且通过地狱杀死所有默认行为...

DD.$dropzones.off('dragleave').on('dragleave',function(event){
    var e=event.originalEvent;

    var dt=e.dataTransfer;
    var relatedTarget_is_dropzone = DD.$dropzones.is(e.relatedTarget);
    var relatedTarget_within_dropzone = DD.$dropzones.has(e.relatedTarget).length>0;
    var acceptable = relatedTarget_is_dropzone||relatedTarget_within_dropzone;
    if (!acceptable) {
        dt.dropEffect='none';
        dt.effectAllowed='null';
    }
});
DD.$dropzones.off('drop').on('drop',function(event){
    var e=event.originalEvent;

    if (!DD.dropLoad) return false;
    var range=null;
    if (document.caretRangeFromPoint) { // Chrome
        range=document.caretRangeFromPoint(e.clientX,e.clientY);
    }
    else if (e.rangeParent) { // Firefox
        range=document.createRange(); range.setStart(e.rangeParent,e.rangeOffset);
    }
    var sel = window.getSelection();
    sel.removeAllRanges(); sel.addRange(range);

    $(sel.anchorNode).closest(DD.$dropzones.selector).get(0).focus(); // essential
    document.execCommand('insertHTML',false,'<param name="dragonDropMarker" />'+DD.dropLoad);
    sel.removeAllRanges();

    // verification with dragonDropMarker
    var $DDM=$('param[name="dragonDropMarker"]');
    var insertSuccess = $DDM.length>0;
    if (insertSuccess) {
        $(DD.$draggables.selector).filter('[dragged]').remove();
        $DDM.remove();
    }

    DD.dropLoad=null;
    DD.bindDraggables();
    e.preventDefault();
});

好的,我受够了。我已经写了我想写的一切。我今天下班了,如果我想到任何重要的事情,可能会更新它。

谢谢大家。//Chase。


你会以任何形式发布这个解决方案吗?我注意到你的jsfiddle只是说它是免费“学习”的,但我想将其纳入我正在开发的(专有)项目中。 - nemec
1
这太棒了。感谢您提出一个写得很好的问题和答案。顺便说一下 - Safari使用与Chrome相同的代码:document.caretRangeFromPoint,而IE通过使用oSelection.getRangeAt(0)“只需工作”。 - bkritzer
1
我知道这是一篇旧帖子,但我不明白演示的重点在哪里?可拖动的图像而不是文本?为什么不只使用https://jsfiddle.net/crl/haxo1boo/6/? - caub
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - Stefan Falk
只需删除所有js,contenteditable就具有可拖动的所有功能,然后您可以使用dnd事件使contenteditable本身可拖动。 - caub
显示剩余4条评论

3

既然我想看到一个原生JS的解决方案,所以我努力删除了所有jQuery依赖。希望这能帮到某些人。

首先是标记

    <div class="native_receiver" style="border: 2px solid red;padding: 5px;" contenteditable="true" >
      WAITING  FOR STUFF
    </div>
    <div class="drawer" style="border: 2px solid #AAE46A; padding: 10px">
      <span class="native_drag" data-type="dateselector" contenteditable="false" draggable="true" style="border: 2px solid rgba(0,0,0,0.2);padding:4px; margin:2px">
        Block 1
      </span>
      <span class="native_drag" data-type="dateselector" contenteditable="false" draggable="true" style="border: 2px solid rgba(0,0,0,0.2);padding:4px; margin:2px">
        Second Blk
      </span>
    </div>

接下来是一些辅助工具

    function addClass( elem, className ){
        var classNames = elem.className.split( " " )
        if( classNames.indexOf( className ) === -1 ){
            classNames.push( className )
        }
        elem.className = classNames.join( " " )
    }
    function selectElem( selector ){
        return document.querySelector( selector )
    }
    function selectAllElems( selector ){
        return document.querySelectorAll( selector )
    }
    function removeElem( elem ){
         return elem ? elem.parentNode.removeChild( elem ) : false
    }

然后是实际的方法

    function nativeBindDraggable( elems = false ){
        elems = elems || selectAllElems( '.native_drag' );
        if( !elems ){
            // No element exists, abort
            return false;
        }else if( elems.outerHTML ){
            // if only a single element, put in array
            elems = [ elems ];
        }
        // else it is html-collection already (as good as array)

        for( let i = 0 ; i < elems.length ; i++ ){
            // For every elem in list, attach or re-attach event handling
            elems[i].dataset.transferreference = `transit_${ new Date().getTime() }`;
            elems[i].ondragstart = function(e){
                if (!e.target.id){
                    e.target.id = (new Date()).getTime();
                }

                window.inTransferMarkup = e.target.outerHTML;
                window.transferreference = elems[i].dataset.transferreference;
                addClass( e.target, 'dragged');
            };
        };
    }

    function nativeBindWriteRegion( elems = false ){
        elems = elems || selectAllElems( '.native_receiver' );
        if( !elems ){
            // No element exists, abort
            return false;
        }else if( elems.outerHTML ){
            // if only a single element, put in array
            elems = [ elems ];
        }
        // else it is html-collection

        for( let i = 0 ; i < elems.length ; i++ ){
            elems[i].ondragover = function(e){
                e.preventDefault();
                return false;
            };
            elems[i].ondrop = function(e){
                receiveBlock(e);
            };
        }
    }

    function receiveBlock(e){
        e.preventDefault();
        let content = window.inTransferMarkup;

        window.inTransferMarkup = "";

        let range = null;
        if (document.caretRangeFromPoint) { // Chrome
            range = document.caretRangeFromPoint(e.clientX, e.clientY);
        }else if (e.rangeParent) { // Firefox
            range = document.createRange();
            range.setStart(e.rangeParent, e.rangeOffset);
        }
        let sel = window.getSelection();
        sel.removeAllRanges(); 
        sel.addRange( range );
        e.target.focus();

        document.execCommand('insertHTML',false, content);
        sel.removeAllRanges();

        // reset draggable on all blocks, esp the recently created
        nativeBindDraggable(
          document.querySelector(
            `[data-transferreference='${window.transferreference}']`
          )
        );
        removeElem( selectElem( '.dragged' ) );
        return false;
    }

最后实例化。
nativeBindDraggable();
nativeBindWriteRegion();

以下是可用的代码片段。

function addClass( elem, className ){
            var classNames = elem.className.split( " " )
            if( classNames.indexOf( className ) === -1 ){
                classNames.push( className )
            }
            elem.className = classNames.join( " " )
        }
        function selectElem( selector ){
            return document.querySelector( selector )
        }
        function selectAllElems( selector ){
            return document.querySelectorAll( selector )
        }
        function removeElem( elem ){
             return elem ? elem.parentNode.removeChild( elem ) : false
        }
        
      
     function nativeBindDraggable( elems = false ){
      elems = elems || selectAllElems( '.native_drag' );
      if( !elems ){
       // No element exists, abort
       return false;
      }else if( elems.outerHTML ){
       // if only a single element, put in array
       elems = [ elems ];
      }
      // else it is html-collection already (as good as array)
            
      for( let i = 0 ; i < elems.length ; i++ ){
       // For every elem in list, attach or re-attach event handling
       elems[i].dataset.transferreference = `transit_${ new Date().getTime() }`;
       elems[i].ondragstart = function(e){
        if (!e.target.id){
         e.target.id = (new Date()).getTime();
        }

        window.inTransferMarkup = e.target.outerHTML;
        window.transferreference = elems[i].dataset.transferreference;
        addClass( e.target, 'dragged');
       };
      };
     }
        
     function nativeBindWriteRegion( elems = false ){
      elems = elems || selectAllElems( '.native_receiver' );
      if( !elems ){
       // No element exists, abort
       return false;
      }else if( elems.outerHTML ){
       // if only a single element, put in array
       elems = [ elems ];
      }
      // else it is html-collection
      
      for( let i = 0 ; i < elems.length ; i++ ){
       elems[i].ondragover = function(e){
        e.preventDefault();
        return false;
       };
       elems[i].ondrop = function(e){
        receiveBlock(e);
       };
      }
     }
        
        function receiveBlock(e){
      e.preventDefault();
      let content = window.inTransferMarkup;
      
      window.inTransferMarkup = "";
      
      let range = null;
      if (document.caretRangeFromPoint) { // Chrome
       range = document.caretRangeFromPoint(e.clientX, e.clientY);
      }else if (e.rangeParent) { // Firefox
       range = document.createRange();
       range.setStart(e.rangeParent, e.rangeOffset);
      }
      let sel = window.getSelection();
      sel.removeAllRanges(); 
      sel.addRange( range );
      e.target.focus();
      
      document.execCommand('insertHTML',false, content);
      sel.removeAllRanges();
      
            // reset draggable on all blocks, esp the recently created
      nativeBindDraggable(
              document.querySelector(
                `[data-transferreference='${window.transferreference}']`
              )
            );
      removeElem( selectElem( '.dragged' ) );
      return false;
     }


    nativeBindDraggable();
    nativeBindWriteRegion();
        <div class="native_receiver" style="border: 2px solid red;padding: 5px;" contenteditable="true" >
          WAITING  FOR STUFF
        </div>
        <div class="drawer" style="border: 2px solid #AAE46A; padding: 10px">
          <span class="native_drag" data-type="dateselector" contenteditable="false" draggable="true" style="border: 2px solid rgba(0,0,0,0.2);padding:4px; margin:2px">
            Block 1
          </span>
          <span class="native_drag" data-type="dateselector" contenteditable="false" draggable="true" style="border: 2px solid rgba(0,0,0,0.2);padding:4px; margin:2px">
            Second Blk
          </span>
        </div>


1
总结以上答案,关键在于event.parentNodeevent.rangeOffset(火狐浏览器),以及caretRangeFromPoint(event.clientX, event.clientY)(谷歌浏览器)。 以下是一个最简示例:

span {
    border: 1px solid red;
}
   
span:before {
   content: "grab ";
   background-color: #0f0;
}
<p contenteditable="true"
    ondrop="sel = window.getSelection();
        if (document.caretRangeFromPoint)
            range = document.caretRangeFromPoint(event.clientX, event.clientY)
        else {
            sel.collapse(event.rangeParent,event.rangeOffset)
            range = sel.getRangeAt(0)
        }
        range.insertNode(sp1)"
    ondragover="
        return false"
>This is a contenteditable paragraph. Grab the green field in the following span 
 <span draggable="True" id="sp1" ondragstart=" 
    event.dataTransfer.setData('text/plain', this.innerText)">span</span>
 and drag it inside this paragraph.
</p>


0
事件 dragstart:dataTransfer.setData("text/html", "<div class='whatever'></div>"); 事件 drop:var me = this; setTimeout(function () { var el = me.element.getElementsByClassName("whatever")[0]; if (el) { //在这里添加代码,el 即为您想要添加的图片位置 } }, 0);

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接