اذهب إلى المحتوى

السحب والإفلات باستخدام أحداث الفأرة والتعامل معها في جافاسكربت


محمد أمين بوقرة

يُعدّ السحب والإفلات وسيلة جيّدة في واجهة المستخدم. إذ أنّ أخذ الشيء وسحبه ثمّ إفلاته طريقة واضحة وبسيطة لفعل الكثير من اﻷمور، من نسخ المستندات ونقلها (كما في مدير الملفّات) إلى إجراء الطلبات (كوضع السلع في سلّة المشتريات).

في مواصفة HTML الحديثة، هناك قسم حول السحب والإفلات مع أحداث خاصّة بها مثل dragstart وdragend وغيرها.

تمكّننا هذه اﻷحداث من دعم أنواع مخصّصة من السحب والإفلات، كالتعامل مع سحب ملفّ من مدير الملفّات في نظام التشغيل وإفلاته في نافذة المتصفّح، لتتمكّن جافاسكربت عندئذ من الوصول إلى محتوى هذه الملفّات.

لكن في المقابل، لأحداث السحب اﻷصيلة تلك حدود أيضًا. فعلى سبيل المثال، لا يمكننا منع السحب في مساحات معيّنة. كما لا يمكننا حصر اتجاه السحب إمّا "أفقيّا" أو "عموديّا". وهناك أيضا العديد من مهامّ السحب والإفلات التي لا يمكن فعلها بواسطة هذه اﻷحداث.

لذا سنرى هنا كيفيّة إنجاز السحب والإفلات باستخدام أحداث الفأرة.

خوارزمية السحب والإفلات

يمكن وصف الخوارزميّة اﻷساسيّة للسحب والإفلات كالتالي:

  1. عند mousedown، جهّز العنصر لتحريكه، عند الحاجة لذلك (ربما بإنشاء نسخة عنه، أو إضافة صنف له أو أيًّا كان ذلك).
  2. ثمّ عند mousemove، حرّكه عن طريق تغيير left/top بواسطة position:absolute.
  3. عند mouseup، قم بجميع اﻷفعال المرتبطة بإنهاء السحب والإفلات.

هذه هي الأساسيّات. سنرى لاحقا كيفيّة إضافة ميزات أخرى، مثل إبراز العناصر التحتيّة الحاليّة عند السحب فوقها.

هذا تطبيق لعمليّة السحب على كرة:

ball.onmousedown = function(event) { 
  // (1) z-index تجهّز للتحريك: اجعل وضعيّة الكرة مطلقة وفي الأعلى بواسطة
  ball.style.position = 'absolute';
  ball.style.zIndex = 1000;

  // مباشرة body افصلها عن أيّ آباءٍ لها وألحقها بـ
  // body اجعلها متموضعة بالنسبة لـ
  document.body.append(ball);  

  // (pageX, pageY) مركِز الكرةَ في الإحداثيّات
  function moveAt(pageX, pageY) {
    ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
    ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
  }

  // حرّك الكرة المطلقة التموضع أسفل المؤشّر
  moveAt(event.pageX, event.pageY);

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // (2) mousemove حرّك الكرة عند
  document.addEventListener('mousemove', onMouseMove);

  // (3) أفلت الكرة وأزل المعالجات غير المرغوبة
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

إذا أجرينا الشيفرة، يمكننا ملاحظة شيء غريب. في بداية السحب والإفلات، "تُستنسخ" الكرة، ونقوم بسحب "نسختها". (يمكن مشاهدة المثال يعمل من هنا).

جرّب اسحب الكرة وأفلتها باستخدام الفأرة وسترى هذا السّلوك. يرجع هذا إلى أنّ للمتصفّح دعمه الخاصّ لسحب وإفلات الصور وبعض العناصر اﻷخرى. وهو يجري بشكل تلقائيّ ويتعارض مع ما نقوم به. لتعطيله:

ball.ondragstart = function() {
  return false;
};

سيكون الآن كلّ شيء على ما يرام، كما يمكن رؤية ذلك من هنا.

هناك جانب مهمّ آخر. نتتبّع الحدث mousemove في document، وليس في ball. من الوهلة اﻷولى، قد يبدو اﻷمر وكأنّ الفأرة تكون دائما فوق الكرة، وبذلك يمكن أن ننصت إلى mousemove من هناك. لكن كما تذكر، يقع الحدث mousemove بكثرة، ولكن ليس من أجل كلّ بكسل. بحركة سريعة يمكن أن يقفز المؤشّر من الكرة إلى مكان ما وسط المستند (أو حتى خارج النافذة). ولهذا يجب أن ننصت إلى الحدث في document لالتقاطه.

التموضع الصحيح

في الأمثلة أعلاه تتحرّك الكرة ويبقى منتصفها تحت المؤشّر على الدوام:

ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';

هذا أمر جيّد، لكنّ له أثرًا جانبيًّا. عند السحب والإفلات، قد نُحدث mousedown في أيّ مكان على الكرة. فإذا "أخذناها" من حافّتها، فسوف "تقفز" الكرة فجأة لتصبح متمركزة تحت مؤشّر الفأرة.

قد يكون من اﻷفضل الإبقاء على الإزاحة اﻷوليّة للعنصر بالنسبة للمؤشّر. على سبيل المثال، إذا بدأنا السحب من حافّة الكرة، فإنّ المؤشّر يبقى على الحافّة خلال السحب.

ball_shift.png

لنحدّث الخوارزميّة:

أولًا، عندما يضغط الزائر على الزرّ (mousedown)، احفظ المسافة من المؤشّر إلى الزاوية العليا من اليسار للكرة في المتغيّرات shiftX/shiftY. سنحافظ على تلك المسافة خلال السحب.

يمكننا الحصول على تلك الإزاحات عن طريق طرح الإحداثيّات:

    // onmousedown في
    let shiftX = event.clientX - ball.getBoundingClientRect().left;
    let shiftY = event.clientY - ball.getBoundingClientRect().top;
    

ثانيًا، بعد ذلك نُبقي الكرة على نفس الإزاحة بالنسبة للمؤشّر خلال السحب هكذا:

// onmousemove في
// position:absolute الكرة لها 
ball.style.left = event.pageX - *!*shiftX*/!* + 'px';
ball.style.top = event.pageY - *!*shiftY*/!* + 'px';

هذه الشيفرة النهائيّة لتموضع أفضل:

ball.onmousedown = function(event) {

  let shiftX = event.clientX - ball.getBoundingClientRect().left;
  let shiftY = event.clientY - ball.getBoundingClientRect().top;

  ball.style.position = 'absolute';
  ball.style.zIndex = 1000;
  document.body.append(ball);

  moveAt(event.pageX, event.pageY);

  // (pageX, pageY) تحريك الكرة إلى الإحداثيّات
  // مع أخذ الإزاحة اﻷوّليّة في الحسبان
  function moveAt(pageX, pageY) {
    ball.style.left = pageX - *!*shiftX*/!* + 'px';
    ball.style.top = pageY - *!*shiftY*/!* + 'px';
  }

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // mousemove حرّك الكرة عند
  document.addEventListener('mousemove', onMouseMove);

  // أفلت الكرة وأزل المعالجات غير المرغوبة
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

ball.ondragstart = function() {
  return false;
};

يمكن مشاهدة ذلك يعمل من هنا.

يظهر الفرق جليًّا إذا جررنا الكرة من زاويتها السفلى من اليمين. في المثال السابق "تقفز" الكرة تحت المؤشر. أمّا الآن فهي تتبع المؤشّر بسلاسة من وضعيّتها اﻷوليّة.

أهداف الإفلات المحتملة (العناصر القابلة للإفلات فوقها)

في اﻷمثلة السابقة، يمكن أن تُفلت الكرةُ "في أيّ مكان" ستبقى فيه. ما نقوم به في الواقع عادة، هو أخذ عنصر ما وإفلاته داخل عنصر آخر، كإفلات "ملفّ" داخل "مجلّد" أو غير ذلك. بعبارة مجملة، نأخذ عنصرا "قابلا للسحب" ونُفلته داخل عنصر "قابل للإفلات فوقه".

نحتاج لذلك إلى معرفة:

  • مكان إفلات العنصر في نهاية السحب والإفلات -- للقيام بالفعل المناسب،
  • ومن المستحسن، العناصر القابلة للإفلات فوقها عند السحب فوقها، لإبرازها.

قد يكون الحلّ مثيرا للاهتمام نوعا ما، وشائكًا بعض الشيء لذا سنعمل على شرحه هنا.

ما الفكرة التي يمكن أن تتبادر إلى الذهن؟ ربّما إسناد معالجات لـ mouseover/mouseup إلى العناصر المحتملة القابلة للإفلات فوقها؟

لن يعمل ذلك. المشكلة هي أنّه خلال قيامنا بالسحب، يكون العنصر القابل للسحب دائما في أعلى العناصر اﻷخرى. وتقع أحداث الفأرة على العنصر العلويّ فقط، لا على التي تحته.

على سبيل المثال، يوجد في اﻷسفل عنصرا <div>، حيث أنّ العنصر اﻷحمر فوق العنصر اﻷزرق (يغطّيه تماما). لا سبيل إلى التقاط حدثٍ في العنصر اﻷزرق لأنّ اﻷحمر موجود أعلاه:

<style>
  div {
    width: 50px;
    height: 50px;
    position: absolute;
    top: 0;
  }
</style>
<div style="background:blue" onmouseover="alert('never works')"></div>
<div style="background:red" onmouseover="alert('over red!')"></div>

ينطبق اﻷمر على العنصر القابل للسحب. توجد الكرة دائما فوق العناصر اﻷخرى، وبذلك فإنّ اﻷحداث تقع عليها. مهما كانت المعالجات التي نسندها إلى العناصر السفليّة، فإنّها لن تشتغل.

لهذا السبب لا تعمل فكرة إسناد معالجات إلى ما يُحتمل من العناصر القابلة للإفلات فوقها في الواقع. لن تشتغل هذه المعالجات. فما العمل إذًا؟

يوجد هناك تابع يُطلق عليه document.elementFromPoint(clientX, clientY)‎. يُعيد هذا التابع العنصر السفليّ الموجود عند الإحداثيّات المُعطاة بالنسبة إلى النافذة (أو null إذا كانت الإحداثيّات المُعطاة خارج النافذة).

يمكننا استخدامه في أيٍّ من معالجات أحداث الفأرة التي لدينا لاكتشاف العناصر المحتملة القابلة للإفلات فوقها تحت المؤشّر، هكذا:

// داخل معالج حدث فأرة
ball.hidden = true; // (*) أخفِ العنصر الذي نسحبه

let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
// هو العنصر الذي تحت الكرة، قد يكون قابلا للإفلات فوقه elemBelow 
ball.hidden = false;

يُرجى التنبّه: نحتاج إلى إخفاء الكرة قبل الاستدعاء (*)، وإلّا فستكون الكرة في تلك الإحداثيّات في الغالب، لأنّها هي العنصر العلويّ تحت المؤشّر: elemBelow=ball. لذا نخفيها ثمّ نظهرها من جديد مباشرة.

يمكننا استخدام هذه الشيفرة لمعرفة العنصر الذي "نحلّق فوقه" في أيّ وقت كان. ثم معالجة الإفلات عند حدوثه.

هذه شيفرة مفصّلة لـ onMouseMove لإيجاد العناصر "القابلة للإفلات فوقها":

// العنصر المحتمل القابل للإفلات فوقه الذي نحلّق فوقه الآن
let currentDroppable = null;

function onMouseMove(event) {
  moveAt(event.pageX, event.pageY);

  ball.hidden = true;
  let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  ball.hidden = false;

  // خارج النافذة (عندما نسحب الكرة إلى خارج الشاشة) ‏ mousemove قد تقع اﻷحداث
  // null يعيد elementFromPoint خارج النافذة، فإنّ clientX/clientY إذا كانت
  if (!elemBelow) return;

  // potential droppables are labeled with the class "droppable" (can be other logic)
  // (قد يكون منطقًا آخر) “droppable” تُعلَّم العناصر المحتملة القابلة للسحب بالصنف 
  let droppableBelow = elemBelow.closest('.droppable');

  if (currentDroppable != droppableBelow) {
    // ... نحلّق دخولا وخروجا
    // null انتبه: قد تكون كلتا القيمتان
    // (إذا لم نكن فوق عنصر قابل للإفلات فوقه قبل وقوع هذا الحدث (كمساحة فارغة currentDroppable=null
    // إذا لم نكن فوق عنصر قابل للإفلات فوقه الآن، خلال هذا الحدث droppableBelow=null

    if (currentDroppable) {
      // (المنطق المُعالَج عند “التحليق خارج” العنصر القابل للإفلات فوقه (إزالة الإبراز
      leaveDroppable(currentDroppable);
    }
    currentDroppable = droppableBelow;
    if (currentDroppable) {
      // المنطق المُعالَج عند “التحليق داخل” العنصر القابل للإفلات فوقه
      enterDroppable(currentDroppable);
    }
  }
}

في المثال الذي يمكن مشاهدته من هنا، عندما تُسحب الكرة إلى المرمى، يُبرز الهدف.

نملك الآن "هدف الإفلات" الحاليّ، الذي نحلّق فوقه، في المتغيّر currentDroppable خلال كامل العمليّة ويمكننا استخدامه للإبراز أو لأيّ غرض آخر.

الملخّص

لقد درسنا خوارزميّة قاعديّة للسحب والإفلات، مكوّناتها الأساسيّة هي:

  1. مجرى اﻷحداث: ball.mousedown ‏-> document.mousemove ‏-> ball.mouseup (لا تنس إلغاء ondragstart الأصيل).
  2. عند بداية السحب، تذكّر الإزاحة اﻷوّليّة للمؤشّر بالنسبة للعنصر، أي shiftX/shiftY، وحافظ عليها خلال السحب.
  3. اكتشف العناصر القابلة للإفلات فوقها تحت المؤشّر باستخدام document.elementFromPoint.

يمكننا بناء الكثير فوق هذا اﻷساس.

  • عند mouseup يمكننا إنهاء السحب بشكل أفضل: كالقيام بتغيير المعطيات وترتيب العناصر.
  • يمكننا إبراز العناصر التي نحلّق فوقها.
  • يمكننا حصر السحب في مساحات أو اتجاهات معيّنة.
  • يمكننا استخدام تفويض اﻷحداث مع mousedown/up. يستطيع معالج حدث على نطاق واسع أن يدير السحب والإفلات لمئات العناصر، بواسطة تفحّص event.target.
  • وهكذا.

هناك أطر عمل تبني هندستها على هذا اﻷساس: DragZoneوDroppableوDraggable` وغيرها من الأصناف. يقوم معظمها بأمور تُشبه ما تمّ وصفه أعلاه، لذا فسيكون من السهل فهمها الآن. أو أنجز واحدا خاصّا بك، فكما رأيت إنّه من السهل فعل ذلك، بل أحيانا أسهل من محاولة تكييف حلّ من طرف ثالث.

التمارين

المنزلق

اﻷهميّة: 5

أنشئ منزلقًا كالذي هو مبيّن هنا.

اسحب المقبض اﻷزرق بواسطة الفأرة وحرّكه.

تفاصيل مهمّة:

  • عند الضغط على زرّ الفأرة، يمكن للمؤشّر الذهاب فوق وتحت المنزلق خلال السحب، ويبقى المنزلق يعمل بشكل عاديّ (هذا ملائم للمستخدم).
  • عندما تتحرّك الفأرة بسرعة إلى اليمين واليسار، يجب أن يتوقّف المقبض عند الحافّة بالضبط.

أنجز التمرين في البيئة التجريبيّة

الحل

كما يمكننا أن نرى من خلال HTML/CSS، أنّ المنزلق هو <div> بخلفيّة ملوّنة، يحتوي على زلّاجة -- التي هي <div> آخر له position:relative.

نغيّر موضع الزلّاجة باستخدام position:relative بإعطاء الإحداثيّات نسبةً إلى العنصر اﻷب، وهي هنا أكثر ملائمة من استخدام position:absolute.

ثمّ ننجز السحب والإفلات المحصور أفقيّا، والمحدود بالعُرض.

شاهد الحلّ في البيئة التجريبيّة

اسحب اﻷبطال الخارقين حول الميدان

اﻷهميّة: 5

قد يساعدك هذا التمرين في اختبار فهمك لعدّة جوانب للسحب والإفلات وDOM.

اجعل جميع العناصر التي لها الصنف draggable قابلة للسحب. مثل الكرة في المقال.

المتطلّبات:

  • استخدم تفويض اﻷحداث لترصّد بداية السحب: معالج واحد للحدث mousedown على document.
  • إذا سُحبت العناصر إلى الحوافّ العلويّة/السفليّة للنافذة، تُمرَّر الصّفحة لتمكين المزيد من السحب.
  • ليس هناك تمرير أفقيّ للصفحة (يجعل هذا التمرين أبسط قليلا، بالرغم أنّه من السهل إضافتها).
  • يجب ألّا تفارق العناصر القابلة للسحب أو أيٌّ من أجزائها النافذة، ولو تحرّكت الفأرة بسرعة.

يمكن مشاهدة النتيجة من هنا.

أنجز التمرين في البيئة التجريبيّة.

الحلّ

لسحب العنصر يمكننا استخدام position:fixed، فهي تجعل الإحداثيات أسهل في الإدارة. في النهاية يجب علينا إرجاعها إلى position:absolute لوضع العنصر في المستند.

عندما تكون الإحداثيّات في أعلى/أسفل النافذة، نستخدم window.scrollTo لتمريرها.

المزيد من التفاصيل في التعليقات التي في الشيفرة.

شاهد الحلّ في البيئة التجريبيّة

ترجمة -وبتصرف- للمقال Drag'n'Drop with mouse events من سلسلة Browser: Document, Events, Interfaces لصاحبها Ilya Kantor


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...