4 คะแนน โดย GN⁺ 2023-09-30 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ไดอะแกรมแบบอินเทอร์แอคทีฟของ Red Blob Games ต้องตอบสนองบนหน้าจอทันทีเมื่อผู้อ่านขยับอ็อบเจ็กต์เอง จึงจัดการอินพุตโดยยึด Pointer Events เป็นหลัก เพื่อรองรับทั้งเมาส์และการสัมผัส
  • วิธีเดิมที่ใช้ mousedown/mouseup/mousemove อาจทำให้ สถานะการลากสับสนได้ เมื่อพอยน์เตอร์หลุดออกนอกอ็อบเจ็กต์หรือปล่อยปุ่มจากด้านนอก จึงต้องมีการเสริมอย่าง global handler
  • อินพุตแบบสัมผัสมีวิธีส่งต่ออีเวนต์ต่างจากเมาส์ และการลากด้วยนิ้วเดียวอาจทำให้หน้าเลื่อนไปด้วย จึงเรียก preventDefault() ใน touchstart เพื่อลดการชนกัน
  • สูตรสุดท้ายผสาน pointerdown, pointermove, pointerup, pointercancel กับ setPointerCapture() และตรวจสอบปุ่มซ้ายกับออฟเซ็ตเริ่มต้น เพื่อไม่ให้อ็อบเจ็กต์กระโดดตำแหน่งกะทันหัน
  • กรณีอย่างอ็อบเจ็กต์ที่มีข้อความหรือรูปภาพ, multi-touch, การลากซ้อนกัน, <canvas>, การใช้ hover ร่วมกับ drag และ ข้อยกเว้นเฉพาะแพลตฟอร์ม เช่น lostpointercapture จำเป็นต้องจัดการแยกต่างหากใน UI จริง

เป้าหมายของตัวจัดการอินพุตการลาก

  • หน้าอินเทอร์แอคทีฟจำนวนมากมี อ็อบเจ็กต์ที่ลากได้ ซึ่งผู้อ่านขยับเอง และไดอะแกรมต้องตอบสนองต่อการเปลี่ยนตำแหน่งของอ็อบเจ็กต์
  • เป้าหมายคือการสร้างตัวจัดการอีเวนต์อินพุตที่ทำงานได้ทั้งกับเมาส์และอินพุตแบบสัมผัส
  • โฟลว์พื้นฐานคือเริ่มลากที่ pointerdown, อัปเดตตำแหน่งที่ pointermove และจบการลากที่ pointerup หรืออีเวนต์ยกเลิก
  • ในการใช้งานจริง จะซับซ้อนกว่าโมเดลง่าย ๆ เพราะมีปุ่มเมาส์, การสัมผัสหลายนิ้ว, เป้าหมายที่รับอีเวนต์ และเมนูบริบทจากการคลิกขวา
  • สูตรพื้นฐานประกอบจากองค์ประกอบต่อไปนี้
    • ใน pointerdown ถ้า event.button !== 0 ให้ return เพื่อจัดการเฉพาะ ปุ่มหลัก
    • แปลงพิกัดของอีเวนต์เป็นพิกัดของไดอะแกรม
    • ตอนเริ่ม ให้บันทึก dx, dy ซึ่งเป็นผลต่างระหว่างตำแหน่งอ็อบเจ็กต์กับตำแหน่งพอยน์เตอร์
    • ใช้ el.setPointerCapture(event.pointerId) เพื่อจับพอยน์เตอร์
    • ใน pointermove ให้นำออฟเซ็ตที่บันทึกไว้ไปบวกแล้วอัปเดต state.pos
    • ใน pointerup, pointercancel ให้ตั้ง state.dragging = null
    • เรียก preventDefault() ใน touchstart
  • สูตรนี้เป็นตัวจัดการอีเวนต์อินพุต ส่วนการประมวลผลเอาต์พุตจริงต้องเชื่อมกับตัวจัดการสถานะแยกต่างหาก
  • โค้ดฉบับสมบูรณ์อยู่ที่ examples page และยังมีตัวอย่างการลากตัวเลขซ้ายขวา, การวาดบนแคนวาส และการลากแบบมีขอบเขตจำกัด ซึ่งไม่ใช่การย้ายอ็อบเจ็กต์
  • ขอบเขตการทดสอบคือ Gecko/Firefox, Blink/Chrome, WebKit/Safari บน Mac, Windows, Linux, Android, iPhone และ iPad
  • ไม่ได้ทดสอบ stylus ที่ hover ได้, อุปกรณ์ไฮบริด touch+mouse และอินพุตเสียง
  • วิธีนี้ไม่ใช่ HTML Drag and Drop API สำหรับวางองค์ประกอบไว้เหนือองค์ประกอบอื่น และในไดอะแกรมจำเป็นต้องอ่านอีเวนต์เมาส์/สัมผัสโดยตรง

ข้อจำกัดเมื่อใช้เฉพาะ Mouse Events

  • การใช้งานช่วงแรกคือผูก mousedown, mouseup, mousemove เข้ากับองค์ประกอบที่ลากได้โดยตรง
  • ดูเผิน ๆ เหมือนทำงานได้ แต่เกิดปัญหา 2 อย่าง
    • ถ้าขยับพอยน์เตอร์เร็ว ๆ พอยน์เตอร์จะไม่ได้อยู่เหนือวงกลมอีกต่อไป จึงไม่ได้รับอีเวนต์
    • ถ้าปล่อยปุ่มนอกวงกลม อาจติดค้างอยู่ในสถานะ dragging
  • วิธีปรับปรุงคือรับ mousedown จากวงกลม และระหว่างลากให้ผูก mousemove กับ mouseup เข้ากับ document ชั่วคราว
  • เมื่อ mouseup ลบ handler ของ mousemove และ mouseup ที่ผูกไว้กับ document ออก การลากด้วยเมาส์จะทำงานเสถียรกว่าเดิม
  • วิธีนี้จัดการเฉพาะเมาส์ และไม่ได้จัดการ Touch Events

ความแตกต่างของ Touch Events

  • อินพุตแบบสัมผัสใช้ touchstart, touchend, touchmove แทน mousedown, mouseup, mousemove
  • หลัง touchstart แล้ว Touch Events จะถูก capture โดยอัตโนมัติ ทำให้ touchmove ทั้งหมดถูกส่งไปยังองค์ประกอบเดิม
  • ด้วยคุณสมบัตินี้ จึงไม่จำเป็นต้องผูก global event handler เข้ากับ document ชั่วคราวเหมือน Mouse Events
  • เมื่อเบราว์เซอร์ต้องยกเลิกลำดับการสัมผัส จะส่ง touchcancel
  • การใช้งานลากแบบสัมผัสจะเรียก preventDefault() ใน touchstart และใน touchmove จะอัปเดตตำแหน่งด้วยพิกัด event.changedTouches[0]
  • วิธีนี้จัดการเฉพาะ touch และไม่ได้จัดการ Mouse Events

รวมเมาส์และ touch ด้วย Pointer Events

  • หากต้องการจัดการทั้งเมาส์และ touch จะต้องมี event handler จำนวนมาก และก่อนปี 2021 ก็ใช้วิธีเช่นนั้น
  • ในช่วงปี 2011–2014 โปรเจกต์ d3 ใช้ d3-drag ส่วนโปรเจกต์ที่ไม่ใช้ d3 ก็สร้างโค้ดเมาส์+touch แยกเอง
  • Pointer Events รวมและทำให้การจัดการเมาส์กับ touch ง่ายขึ้น
    • MS IE เพิ่มการรองรับในปี 2012
    • Chrome เพิ่มการรองรับในปี 2017, Firefox ในปี 2018 และ Safari ในปี 2020
  • พฤติกรรมของเบราว์เซอร์เปลี่ยนไปตามเวลา และในปี 2017 Chrome เปลี่ยนอีเวนต์บางส่วนให้เป็น passive mode โดยค่าเริ่มต้น ทำให้เกิดปัญหาหน้าเลื่อนเมื่อพยายามลากอ็อบเจ็กต์
  • Safari และ Firefox ก็ใช้การเปลี่ยนแปลงเดียวกันในปี 2018
  • เมื่อใช้ pointer capture จะจัดการได้ด้วยตรรกะที่เรียบง่าย โดยไม่ต้องเพิ่ม/ลบ global handler ของ document ที่เคยจำเป็นใน Mouse Events

รักษาการติดตามพอยน์เตอร์ด้วย Pointer Capture

  • Pointer capture ทำให้ติดตามต่อได้แม้พอยน์เตอร์จะหลุดออกนอกวงกลม, ไดอะแกรม หรือหน้าต่างเบราว์เซอร์
  • ใน Mouse Events ต้องผูก event handler เข้ากับ document เพื่อให้ได้ผลแบบเดียวกัน แต่ใน Pointer Events การใช้ setPointerCapture(event.pointerId) ง่ายกว่า
  • ถ้าไม่มี pointer capture การลากอาจหยุดเมื่อคุณลากเร็ว ๆ หรือออกนอกไดอะแกรมแล้วกลับเข้ามา
  • กรณีปล่อยปุ่มนอกไดอะแกรม หรือย้ายไปหน้าต่างอื่นด้วย Alt+Tab การจัดการการจบลากก็จะแตกต่างกันตามว่ามี capture หรือไม่
  • บนอุปกรณ์ touch นั้น pointer capture เป็นพฤติกรรมเริ่มต้น แต่สำหรับเมาส์ต้องตั้งค่าอย่างชัดเจน

การเลื่อนด้วย touch และ preventDefault()

  • บนอุปกรณ์ touch การลากด้วยนิ้วเดียวโดยค่าเริ่มต้นจะทำให้หน้าเลื่อน
  • หากการลากด้วยนิ้วเดียวแบบเดียวกันทำให้วงกลมขยับด้วย ก็จะเกิด การเลื่อนและการลากอ็อบเจ็กต์ พร้อมกัน
  • วิธีแก้ที่ง่ายที่สุดคือใช้ CSS touch-action: none กับไดอะแกรม แต่กรณีนี้จะไม่สามารถเลื่อนได้จากบริเวณใดของไดอะแกรมเลย
  • พฤติกรรมที่ต้องการคือปิดการเลื่อนเฉพาะตอนลากวงกลม และยังยอมให้เลื่อนเมื่อ dragging ที่ตัวไดอะแกรมเอง
  • ผลการเปรียบเทียบมีดังนี้
    • touch-action: none กับทั้งไดอะแกรม: หยุดการเลื่อนทั้งการลากวงกลมและการลากไดอะแกรม
    • ค่าเริ่มต้น: ทั้งสองแบบไม่สามารถกันการเลื่อนได้
    • touch-action: none เฉพาะกับวงกลม: ไม่ทำงานตามที่คาด
    • preventDefault() ใน touchstart: กันเฉพาะการเลื่อนระหว่างลากวงกลม และยอมให้เลื่อนไดอะแกรม
  • preventDefault() ผูกกับ touchstart ไม่ใช่ pointerstart
  • วิธีนี้ทำงานได้ในสถานการณ์ส่วนใหญ่ แต่ไม่ทำงานใน “mobile emulation mode” ของ desktop Firefox และทำงานได้ใน mobile Firefox บน Android

การบันทึกออฟเซ็ตเมื่อเริ่มลาก

  • หากจับขอบของอ็อบเจ็กต์แล้วลาก แล้วจุดศูนย์กลางของอ็อบเจ็กต์กระโดดไปยังตำแหน่งของพอยน์เตอร์ทันที จะทำให้ประสบการณ์การใช้งานแย่ลง
  • วิธีแก้คือเมื่อเริ่มลาก ให้บันทึก ตำแหน่งสัมพัทธ์ระหว่างจุดศูนย์กลางของอ็อบเจ็กต์กับตำแหน่งของพอยน์เตอร์ ไว้
  • จากนั้นเมื่อเคลื่อนที่ ให้คำนวณตำแหน่งของอ็อบเจ็กต์โดยนำ dx, dy ที่บันทึกไว้ไปบวกกับพิกัดพอยน์เตอร์ปัจจุบัน
  • วิธีนี้เปลี่ยน state.dragging จากค่า true/false ธรรมดาให้เป็นข้อมูลตำแหน่งสัมพัทธ์อย่าง {dx, dy}
  • บนเมาส์จะเห็นความต่างจากการกระโดดได้ชัดเมื่อจับจากขอบ ส่วนบนระบบสัมผัสอาจเห็นน้อยกว่าเพราะนิ้วบังอยู่
  • รายละเอียดเล็ก ๆ ที่เกี่ยวข้องถูกรวบรวมไว้ในหน้า little details ด้วย

เมนูบริบทและปุ่มเมาส์

  • วิธีเปิดเมนูบริบทแตกต่างกันไปตามแพลตฟอร์ม
    • Windows: คลิกขวา down+up, Shift+F10
    • Linux: ปุ่มขวา down, Shift+F10
    • Mac: ปุ่มขวา down, Ctrl+คลิกซ้าย
    • iOS: long press เฉพาะบนข้อความ
    • Android: long press ได้ทุกที่
  • ปัญหาคือมีบางกรณีที่เห็น pointerdown แต่ไม่ได้เห็น pointerup เสมอไป
  • แม้ปุ่มจะถูกปล่อยขึ้นจริงแล้ว แต่โค้ดอาจยังตัดสินว่าอยู่ในสถานะถูกกดอยู่
  • วิธีรับมือคือเพิกเฉยต่อปุ่มขวา และตั้งสถานะ dragging เฉพาะเมื่อเป็น ปุ่มเมาส์ซ้าย เท่านั้น
  • ในพร็อพเพอร์ตี button ของสเปก Pointer Events ค่า button = 0 หมายถึง primary button และใช้ค่านี้เพื่อยกเว้นปุ่มกลางกับปุ่มขวา
  • ใน Chrome และ Safari บน Mac การ Ctrl+คลิกอาจเข้ามาเหมือน pointerdown ของปุ่มซ้าย จึงต้องตรวจสอบ event.ctrlKey ด้วย
  • ตัวเลือกมีดังนี้
    • สามารถบล็อกเมนูด้วย preventDefault() ใน contextmenu ได้ แต่อาจชนกับพฤติกรรมคลิกกลางหรือการลากพื้นฐาน
    • หากลากด้วยปุ่มซ้ายเท่านั้น จะหลีกเลี่ยงความแตกต่างของคลิกขวาส่วนใหญ่ได้
    • หากต้องการหลีกเลี่ยง Ctrl+คลิกบน Mac ด้วย ต้องยกเว้น ctrlKey ด้วย
  • แม้การเปลี่ยนสถานะปุ่มจะไม่ถูกส่งผ่าน pointerdown หรือ pointerup ก็ยังตรวจสอบได้บางส่วนจากสถานะปุ่มใน pointermove

อ็อบเจ็กต์ที่มีข้อความและรูปภาพ

  • หากมีข้อความหรือรูปภาพอยู่ภายในองค์ประกอบที่ลากได้ จำเป็นต้องมีการจัดการเพิ่มเติม
  • ระหว่างลากวงกลม ข้อความด้านในอาจถูกเลือกได้
  • วิธีแก้คือใช้ CSS user-select: none
    • หากใช้ตลอดเวลา ข้อความด้านในจะไม่สามารถถูกเลือกได้เลย
    • หากใช้เฉพาะขณะลาก ตามปกติยังสามารถเลือกข้อความได้
  • ณ ต้นปี 2023 Safari ยังไม่รองรับ user-select แบบไม่มี prefix จึงต้องตั้งค่า webkitUserSelect ควบคู่กันด้วย
  • Windows, Linux และ Mac รองรับ drag and drop ข้อความและรูปภาพระหว่างแอปพลิเคชัน ซึ่งขัดกับการลากอ็อบเจ็กต์ได้
  • หากเรียก preventDefault() ใน dragstart จะทำให้การลากอ็อบเจ็กต์มีความสำคัญเหนือกว่าการลากข้อความหรือรูปภาพที่ถูกเลือก
  • พฤติกรรมจะแตกต่างกันเล็กน้อยตามเบราว์เซอร์และระบบปฏิบัติการ

พอยน์เตอร์หลายตัวและการลากพร้อมกัน

  • การที่สามารถลากอ็อบเจ็กต์หลายชิ้นพร้อมกันด้วยหลายนิ้วหรือหลายเมาส์ได้หรือไม่ เป็นกรณีขอบ
  • บนระบบสัมผัส โค้ดที่นำเสนอทำงานสำหรับการลากหลายอ็อบเจ็กต์พร้อมกันอยู่แล้ว
  • กรณีลากอ็อบเจ็กต์เดียวกันด้วยสองนิ้วจำเป็นต้องจัดการแยกต่างหาก
  • วิธีแก้คือบันทึก event.pointerId ไว้ใน state.dragging หรือสถานะเมื่อเกิด pointerdown แล้วใน pointermove ให้เพิกเฉยหากไม่ใช่ pointerId เดียวกัน
  • ตัวอย่างการใช้งานนี้ยังไม่ได้ถูกนำไปใช้ครบถ้วนในเนื้อหา แต่ดูได้ที่ canvas dragging test
  • สำหรับเมาส์ สเปก Pointer Events ระบุว่าระบบปฏิบัติการทั่วไปและ user agent มักไม่มีแนวคิดของอินพุตจากเมาส์หลายตัว
  • แม้จะมีทั้งแทร็กแพดและเมาส์ภายนอก โดยทั่วไปก็ถือว่าเป็นพอยน์เตอร์เมาส์ตัวเดียว จึงมองว่าไม่มีวิธีลากอ็อบเจ็กต์คนละชิ้นด้วยเมาส์ต่างตัวกัน

เมื่อกดปุ่มเมาส์หลายปุ่มพร้อมกัน

  • เมื่อกดปุ่มเมาส์หลายปุ่มพร้อมกัน พฤติกรรมของ Pointer Events และ Mouse Events จะแตกต่างกัน
  • Mouse Events จะส่ง mousedown ทุกครั้งที่กดแต่ละปุ่ม และส่ง mouseup ทุกครั้งที่ปล่อยแต่ละปุ่ม
  • ตามสเปก Pointer Events จะเกิด pointerdown ที่ปุ่มแรกที่ถูกกด และเกิด pointerup ที่ปุ่มสุดท้ายที่ถูกปล่อย
  • ด้วยเหตุนี้ pointerdown กับ pointerup อาจเกิดในสถานะปุ่มที่แตกต่างกันได้
  • ตัวอย่างเช่น หากกดปุ่มซ้าย กดปุ่มขวา แล้วปล่อยปุ่มซ้าย อ็อบเจ็กต์อาจยังคงอยู่ในสถานะถูกลากต่อไป
  • วิธีแก้คือใน pointermove ให้ตรวจสอบสถานะปุ่มซ้ายด้วย event.buttons & 1 และหากไม่ได้ถูกกดอยู่อีกต่อไป ให้เรียก end(event)
  • pointer capture อาจดำเนินต่อไปจนกว่าจะปล่อยทุกปุ่ม และอาจคงอยู่ต่อไปหากไม่ได้ปล่อย capture อย่างชัดเจน
  • การใช้งานนี้ไม่ได้จัดการปัญหานี้และกรณีขอบหลาย ๆ อย่างทั้งหมด

องค์ประกอบลากได้ที่ซ้อนกัน

  • หากมีองค์ประกอบลากได้อีกตัวอยู่ภายในองค์ประกอบที่ลากได้ องค์ประกอบด้านในและด้านนอกจะจัดการการลากทั้งคู่
  • วิธีแก้คือเรียก event.stopPropagation() ในองค์ประกอบลากด้านใน เพื่อไม่ให้อีเวนต์ไหลขึ้นไปยังองค์ประกอบลากด้านนอก
  • มักใช้วิธีหยุดการแพร่กระจายทั้งใน pointerdown และ pointermove
  • แม้ไม่มีเดโมในเนื้อหา แต่สามารถดูกรณีที่องค์ประกอบลากสีแดงเป็นลูกขององค์ประกอบลากสีเหลืองได้ใน ตัวอย่าง Vue playground

การลากใน Canvas

  • โดยทั่วไปมักใช้ SVG แต่ใน <canvas> ไม่สามารถตั้งค่า event handler หรือรูปแบบของตัวชี้เมาส์ให้กับองค์ประกอบที่ลากได้โดยตรง
  • ใน 2D Canvas หรือ WebGL ต้องผูก event handler ไว้กับ <canvas> แล้วตรวจสอบเองว่าพิกัดอยู่บนอ็อบเจ็กต์ที่ลากได้หรือไม่
  • วิธีจัดการมีดังนี้
    • pointerdown, touchstart, dragstart: ถ้าไม่ได้อยู่บนอ็อบเจ็กต์ที่ลากได้ ให้ return ทันที
    • pointermove: ตั้งค่าเคอร์เซอร์ตามว่ากำลังอยู่บนอ็อบเจ็กต์ที่ลากได้หรือไม่
  • เดโมอยู่ที่ examples page

การผสม Mouse Hover กับ Touch Drag

  • ไดอะแกรมบางแบบตอบสนองต่อ hover โดยไม่ต้องกดปุ่มเมื่อใช้เมาส์ แต่บนอุปกรณ์สัมผัสไม่มี hover จึงต้องตอบสนองด้วยการ drag
  • สูตรพื้นฐานคาดหวังให้ทั้งเมาส์และทัชใช้การ drag
  • หากต้องการใช้ mouse hover ร่วมกับ touch drag ให้ลบเงื่อนไข if (!state.dragging) ใน pointermove
  • ใน Responsive Design page เมื่อใช้เมาส์ hover เหนือแถว layout จะเปลี่ยน และเมื่อใช้ทัช layout จะเปลี่ยนด้วยการ drag
  • ใน Hexagons Guide ก็มีไดอะแกรมจำนวนมากที่ใช้ mouse hover ร่วมกับ touch drag
  • บนทัช เมื่อ drag จากรายการ A ไปยัง B หากยังคง capture อยู่ move event จะถูกส่งไปยัง A ดังนั้นหากต้องการ highlight B อาจจำเป็นต้องปล่อย capture

Toggle Paint และการขยายสถานะการลาก

  • ใน Rounded Cell Painter pointerdown จะจับค่าสี paint เริ่มต้นไว้
  • หลังจากนั้น pointermove ทั้งหมดจนถึง pointerup จะใช้สี paint เดิม
  • เพื่อทำเช่นนี้ state.dragging ต้องรวมไม่ใช่แค่ x, y เริ่มต้น แต่รวมถึง สี paint เริ่มต้น ด้วย
  • เดโมอยู่ที่ examples page

กรณีขอบของ lostpointercapture

  • lostpointercapture สามารถใช้ตรวจจับได้ว่าสูญเสีย pointer capture หรือไม่
  • ยังไม่ได้สรุปครบถ้วนว่าเกิดขึ้นในสถานการณ์ใดและควรทำอย่างไร
  • สถานการณ์ที่กล่าวถึงในความคิดเห็นมีดังนี้
    • เริ่ม drag วงกลมด้วยปุ่มซ้าย
    • ขณะกดปุ่มซ้ายค้างไว้ ให้เลื่อนออกนอกพื้นที่ drag
    • กดปุ่มขวา แล้วปล่อยปุ่มซ้าย
    • คลิกที่ใดก็ได้ภายนอกพื้นที่ drag
  • standard ระบุว่า หลังจาก pointer capture ถูกปล่อยแล้ว user agent ต้องส่ง event lostpointercapture
  • พฤติกรรมจริงของเบราว์เซอร์แตกต่างกันมาก โดยบางระบบเกิด lostpointercapture แต่บางระบบจะไม่เกิดจนกว่าจะคลิกที่อื่น

1 ความคิดเห็น

 
GN⁺ 2023-09-30
ความคิดเห็นจาก Hacker News
  • เป็นบทความเกี่ยวกับการลาก และผมก็เจอกับดักแทบทั้งหมดที่ Amit พูดถึง แล้วก็ไปถึงวิธีแก้แบบเดียวกัน เป็นบทความที่ยอดเยี่ยม
    จากมุมมองผู้ใช้ การลากเพื่อเปลี่ยนลำดับ ดูเป็นธรรมชาติมาก แต่พอมีรายการที่ขนาดไม่เท่ากัน การสร้าง placeholder ระหว่างรายการ การตรวจจับขอบ ไปจนถึงหลุมกระต่ายของอัลกอริทึมจัดให้พอดีกับกล่อง ก็จัดว่าเป็นงานที่ค่อนข้างยากถ้าจะลงมือทำเอง

    • มีเทคนิคทั่วไปที่ผมชอบใช้ในกรณีแบบนี้: เมื่อผู้ใช้เริ่มลาก ให้คำนวณเป้าหมายที่ drop ได้ทั้งหมด หรือ เลย์เอาต์ ของเป้าหมายที่มองเห็นอยู่ในตอนนั้นทั้งหมด และบันทึกตำแหน่งที่วัตถุที่กำลังลากจะไปจบในแต่ละเลย์เอาต์
      ทุกครั้งที่เมาส์ขยับ ก็เลือกตำแหน่งที่ใกล้กับตำแหน่งวัตถุที่กำลังลากอยู่มากที่สุด แล้ว render เลย์เอาต์ที่ถูกเลือก วิธีนี้ทำงานได้ให้ความรู้สึกดี แม้กับเลย์เอาต์ซับซ้อนหรือมี reflow
    • ผมต้องแก้ปัญหานี้ในแอปการ์ดดัชนีของผม Card Buddy [1] เป็นความท้าทายที่สนุก และต่อมาก็พบวิธีที่ดีกว่าอีก
      สุดท้ายแล้ว เมื่อหยิบการ์ดขึ้นมา ผมคำนวณเลย์เอาต์เหมือนกับว่าการ์ดใบนั้นถูกลบออกจากบอร์ดแล้ว ซึ่งทำให้ง่ายขึ้น แค่ดันสิ่งที่อยู่ตรงตำแหน่งที่เมาส์ชี้อยู่ให้หลบไปก็พอ
      ถึงอย่างนั้นก็ยังมีกรณียกเว้นจำนวนมาก โดยเฉพาะตอนเริ่มแก้ไขการ์ดใหม่ที่ยังไม่ได้ “commit” ลงใน data model นั้นจัดการยากมาก ต้องเพิ่มตัวเลือกให้ดันการ์ดเดิมออกไปเพื่อสร้างที่ว่างสำหรับ การ์ดผี ด้วย
      การยอมรับว่ามีกรณียกเว้นจำนวนมากที่ต้องจัดการเองนั้นช่วยได้ ถ้าเข้าหาด้วยความคิดว่าควรมีวิธีแก้ที่ทั่วไปและสม่ำเสมอกว่านี้ ก็จะวนไปวนมาในการออกแบบ สักวันคงต้องรวบรวมกรณียกเว้นต่าง ๆ เขียนเป็นบล็อกโพสต์
      ผมยังพบวิธีจัดเลย์เอาต์ที่ดีกว่า ซึ่งช่วยลดการคำนวณที่ไม่จำเป็น และทำให้ layout engine ยืดหยุ่นและเป็นมิตรกับผู้ใช้มากขึ้น น่าแปลกใจมากว่าการเลือกวิธีแทนข้อมูลใน data model ส่งผลต่อวิธีแก้มากแค่ไหน
      [1] https://www.ussherpress.com/cardbuddy/
    • ถ้าการเปลี่ยนลำดับด้วยการลากยังต้องรองรับ การซ้อนชั้น ด้วย ก็ยิ่งยากขึ้นไปอีก
    • คิดเหมือนกัน ไอเดียนั้นเรียบง่าย แต่การลงมือทำอาจโหดร้ายมาก การลากเพื่อเลือก ก็เป็นอีกอย่างที่ผมมองว่าเป็นเรื่องธรรมดาเกินไปมาหลายปี
  • ดูเหมือนบทความจะไม่ได้พูดถึง การจัดการการยกเลิก ตัวอย่างเช่นบน Windows มีธรรมเนียมว่ากด Escape แล้วจะยกเลิกการลาก
    บางครั้งเราอาจอยากให้การปล่อยเมาส์นอกพื้นที่ที่กำหนดยกเลิกการลากด้วย การยกเลิกเป็นทางออกที่ช่วยให้ผู้ใช้ undo ได้เร็วขึ้น หรือเป็นทางหนีแบบ “อ้อ จริง ๆ แล้วไม่ได้ตั้งใจจะลากนี่นา”
    ดังนั้นแม้จะไม่มีฟีเจอร์ undo แยกต่างหาก ก็ควรบันทึกสถานะเดิมตอนเริ่มลากไว้ แล้วคืนค่าถ้ามีการยกเลิก
    ในกรณียกเลิกการลากเมื่อออกนอกพื้นที่ ยังมี การเลิกยกเลิก ด้วย คือเมื่อ pointer อยู่นอกพื้นที่ จะแสดงผลทางสายตาเหมือนกลับสู่สถานะเดิม แต่เมื่อ pointer กลับเข้ามาในพื้นที่ ก็ลากต่อ วิธีนี้การยกเลิกจริงจะเกิดตอนปล่อยเมาส์ แต่ระหว่างลากก็แจ้งให้เห็นล่วงหน้าทางสายตาว่าจะถูกยกเลิก

    • บน Windows ถ้าลากบางอย่างออกไปไกลเกินไป จะมีพฤติกรรมที่วัตถุที่กำลังลากกับ pointer หายไปกะทันหัน แล้วเด้งกลับไปตำแหน่งเดิมโดยไม่มีคำเตือน
      ไม่น่าเชื่อว่า Microsoft ยังเชื่อว่านี่เป็น การออกแบบ UI ที่ดีอยู่ มันทำให้ผู้ใช้สับสนสุด ๆ และอาจเห็นสถานการณ์ที่ผู้ใช้ไม่รู้ว่าเกิดอะไรขึ้น จนกลัวจะปล่อยปุ่มเมาส์ซ้าย หรือทำซ้ำไปมาเหมือนโยโย่
      เห็นด้วยว่าการยกเลิกเป็นปัญหาใหญ่ที่มักถูกมองข้าม และไม่ใช่เรื่องง่าย ผู้ใช้ทั่วไปไม่รู้ด้วยซ้ำว่า ESC ช่วยได้
    • สงสัยว่าบนแพลตฟอร์มอื่นมี ปุ่มยกเลิก อื่นนอกจาก ESC ที่ต้องรองรับไหม
  • มีรายละเอียดเพิ่มเติมที่ได้เรียนรู้จากการลากวัตถุสารพัดใน GUI ของ Ardour [0] มาตลอด 20 ปี: ต้องจัดการทุกเหตุการณ์กดและปล่อยปุ่มเป็น เหตุการณ์ลาก ที่ไม่มีการเคลื่อนที่
    กล่าวคือ การกดจะเริ่มการลากเสมอและจบด้วยการปล่อย ส่วนโค้ดที่จัดการการปล่อยจะจัดการกรณีพิเศษเมื่อไม่มีการเคลื่อนที่
    [0] https://ardour.org/

    • วิธีนั้นก็น่าจะใช้ได้ แต่ผมไม่ได้ทำแบบนั้น และพอจับพื้นฐานให้ถูกต้องแล้วก็ไม่มีปัญหา
      ผมมี handler mousedown และ mouseup แบบทั่วไป และใช้ timeout ที่ตั้งค่าได้ราว 150ms เพื่อพิจารณาว่าเป็นคลิก เริ่มลาก หรือการกระทำอื่น ถ้าเกิด mouseup หรือเมาส์ออกนอกหน้าจอ และมีการลากที่กำลังดำเนินอยู่ ก็ stopdrag สำหรับผม การลากเป็นเพียงหนึ่งในกรณีพิเศษหลาย ๆ แบบเท่านั้น
    • อยากรู้ว่าทำไมถึงทำแบบนั้น
  • นานมากแล้ว ใน Minneapolis เคยมี geometry center ที่ได้รับการสนับสนุนจาก NSF มีการประชุมด้าน computational group theory และเชิญคนสองสามคนจากสาขาใกล้เคียงที่เหมือนเป็นมาสคอตมาร่วมด้วย
    ผมได้รับเชิญเพราะเขียนระบบพีชคณิตเรขาคณิต และตื่นเช้ามากท่ามกลางอากาศหนาว -20°F เพื่อไปจองเวิร์กสเตชัน Silicon Graphics ที่จะใช้ในวันนั้น แล้วเขียนเกมเพื่อทำความเข้าใจตัวสร้างของกรุ๊ปและความสัมพันธ์ให้ดีขึ้น
    เกมนั้นมีการลากอยู่ด้วย ผลลัพธ์คือบทสนทนาดี ๆ ที่ต่อเนื่องไปจนถึงตี 2 ความคิดของผมคือ การลากไม่จำเป็นต้องเป็นไปตามกฎฟิสิกส์ของโลกจริง และการลากควรให้ความรู้สึกเหมือน LSD แรง ๆ หนึ่งเม็ด ทุกคนอินกับเรื่องนี้และต่างก็เสนอไอเดียของตัวเอง

  • หากไม่นับข้อยกเว้นต่าง ๆ ที่เกิดขึ้นในเว็บเบราว์เซอร์ บทความนี้ก็สาธิตการทำ drag แบบค่อนข้างพื้นฐาน อยากรู้ว่ามีแหล่งข้อมูลเกี่ยวกับการ drag ที่มีข้อจำกัดต่าง ๆ ไหม เช่น การ snap เข้ากับ guideline, การป้องกันการชนกับขอบเขตหรือวัตถุอื่น, หรือ drop target แบบแอนิเมชันที่ขนาดหรือตำแหน่งเปลี่ยนไปตามการลาก
    ก่อนหน้านี้เคยอยากทำ UI ตัวจับเวลา Pomodoro แบบกำหนดเองที่แบ่งนาฬิกาวงกลมออกเป็นชิ้นลิ่มความยาวต่าง ๆ เพื่อกำหนดช่วงโฟกัส/พัก แต่การทำให้เปลี่ยนลำดับชิ้นลิ่มเหล่านั้นด้วยการลากยังไม่ค่อยคืบหน้า

    • ในแนวทางโค้ดนี้ การเคลื่อนไหวขณะลากจะอัปเดต state บางอย่าง ตอนตั้งค่า state สามารถนำ ข้อจำกัด มาใช้ได้ และ state นั้นก็ขับเคลื่อนการแสดงผลอีกที
      อยากแยกระบบข้อจำกัดออกจากระบบจัดการอีเวนต์ ไลบรารีอย่าง jquery-ui จะผูกสองอย่างนี้เข้าด้วยกัน ทำให้ระบบจัดการอีเวนต์ต้องรู้จักข้อจำกัดทั้งหมดที่เป็นไปได้ jquery-ui รองรับ bounding box, axis, square grid และการ snap ไปยัง DOM element แต่ถ้าอยาก snap ไปยัง hex grid, logarithmic scale grid หรือขอบเขตวงกลม ก็ไม่รองรับ
      ในโค้ดจะเห็น state.pos = … ซึ่งตรงนั้นคือจุดที่ตั้งค่า state สำหรับข้อจำกัดจะซ่อน pos ไว้หลัง setter แบบนั้นโค้ดจัดการอีเวนต์ drag ก็ไม่จำเป็นต้องรู้ว่าต้องใช้ข้อจำกัดอะไร เพราะ setter สามารถนำข้อจำกัดไปใช้ได้เอง
      คงต้องอัปเดตหน้าเพื่อแสดงตัวอย่างข้อจำกัด ลืมพูดถึงประเด็นนี้ใน code recipe ไปเสียสนิท
      ตัวอย่างข้อจำกัดเก่าอยู่ที่ https://www.redblobgames.com/articles/curved-paths/making-of... และตัวอย่างการป้องกันการชนอยู่ที่ https://redblobgames.github.io/circular-obstacle-pathfinding... แต่ยังไม่เคยลองทำการเปลี่ยนลำดับด้วยการลากหรือ drop target แบบแอนิเมชัน
  • ผมก็รู้สึกขอบคุณเว็บไซต์นี้เหมือนกัน ตอนออกแบบ ระบบหกเหลี่ยม สำหรับการวิเคราะห์เชิงภูมิศาสตร์ที่ Uber ได้อ้างอิง https://www.redblobgames.com/grids/hexagons/ เยอะมาก

  • เป็นบทความที่สรุปหลุมพรางและข้อควรระวังเมื่อทำ interaction ให้ดีได้อย่างยอดเยี่ยม
    ถ้าอยากได้แบบ “พร้อมใช้” มากขึ้น ผมใช้ interactjs ในหลายโปรเจกต์มานานพอสมควร

  • ขอบคุณทุกคน วันนี้แปลกใจที่เห็นขึ้น HN และยินดีตอบคำถาม

    • สงสัยว่าเคยดู attribute ในตัวของ HTML อย่าง draggable="true" กับ drag event หรือยัง
      และอยากรู้ว่าฟีเจอร์ในตัวมีข้อด้อยอะไรเมื่อเทียบกับโค้ดนี้บ้าง
      https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement...
      https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement...
      https://developer.mozilla.org/en-US/docs/Web/HTML/Global_att...
    • เป็นการอภิปรายเรื่องการ drag ด้วย JavaScript ที่ยอดเยี่ยม และข้อมูลมีประโยชน์มากจริง ๆ
      มีคำถามสั้น ๆ คือ ถ้าจะจำกัดการเคลื่อนไหวขณะลากให้เหลือแกนเดียวด้วย DOM event ในตัวอย่าง dragstart ควรทำอย่างไร ผมทำฟีเจอร์ drag and drop ด้วย event dragstart/dragenter/dragover/drop แล้ว แต่ยังหาวิธีเร็ว ๆ ในการจำกัดการเคลื่อนไหวให้อยู่บน แกน x ไม่ได้ จำได้ว่า API drag and drop ของ JQuery รองรับสิ่งนี้ ผมพยายามใช้เฉพาะ native DOM event/API เลยอยากได้ข้อมูลหรือคำชี้แนะที่เกี่ยวข้อง
    • เมื่อก่อนผมเขียนโค้ด UI บนเบราว์เซอร์อยู่ตลอด แต่หยุดไปหลายปี เพิ่งเคยได้ยินว่ามีคนใช้ pointer event เป็นครั้งแรก
      เมื่อคำนึงว่า Safari เพิ่งเพิ่มการรองรับในปี 2020 อยากรู้ว่าไม่กังวลหรือว่าบราวเซอร์อาจไม่รองรับ ผู้ใช้ macOS Mojave ก็น่าจะใช้ Safari 13 หรือ 14 กันอยู่ ดังนั้นก็หวังว่า Safari 12 จะไม่ได้ถูกใช้งานจริงแล้ว แต่ถ้าฟีเจอร์ง่าย ๆ อย่างการลากใช้ไม่ได้ใน production app ที่เจาะผู้ใช้เว็บทั่วไป ก็คงแย่พอสมควร
      การเพิ่ม event handler บน document ระหว่างลากเป็นแนวปฏิบัติเก่าแก่ และเบราว์เซอร์ก็เพิ่มฟีเจอร์ใหม่ ๆ ที่พยายามทำให้ use case บางอย่างง่ายขึ้นอยู่เรื่อย ๆ แต่แต่ละอย่างก็มีข้อยกเว้นและหลุมพรางของตัวเอง ในบทความเองก็บอกว่ายังไม่ได้ครอบคลุมทั้งหมด ผลลัพธ์สุดท้ายก็ยังเหลือการผสมกันระหว่าง pointer event กับ touch event
      อยากรู้ว่า “ความเรียบง่าย” ในแง่ที่โค้ดน้อยลงนั้นคุ้มค่าพอไหม เมื่อเทียบกับต้นทุนของข้อยกเว้นที่เพิ่มขึ้น การรองรับเบราว์เซอร์ที่ต่ำกว่า และการที่นักพัฒนาต้องเข้าใจรายละเอียดกับความแตกต่างระหว่างเบราว์เซอร์ของ pointer event ซึ่งอาจไม่เป็นที่เข้าใจและมีเอกสารน้อยกว่า mouse event
  • มีสิ่งหนึ่งที่ขาดไป: การเข้าถึงได้ แล้วควรทำให้ลากด้วยการควบคุมผ่านคีย์บอร์ดได้อย่างไร?

    • ในที่นี้ดูเหมือนเป็นคำถามที่ออกนอกประเด็นไปหน่อย คล้ายกับถามว่าในโปรแกรมวาดภาพ ผู้ใช้คีย์บอร์ดจะทำท่าคลิกค้างไว้เพื่อแพนแคนวาสอย่างไร ซึ่งไม่ได้ทำแบบนั้น การแพนด้วยเมาส์หรือการลากวัตถุเป็นเพียงอินเทอร์เฟซแบบหนึ่งเท่านั้น งานจริงคือการแพนแคนวาส และมีหลายวิธีในการทำให้สำเร็จ
      ในตัวอย่างของผม คำตอบด้านการเข้าถึงคือการจัดให้มี การควบคุมด้วยคีย์บอร์ด เพิ่มเติมเพื่อทำงานนั้นให้เสร็จ ถ้าเป็นไปได้ควรเป็นวิธีที่ไม่ต้องกดปุ่มค้างไว้ เช่น ใช้คีย์ลัดเข้าสู่โหมดแพน แล้วใช้ปุ่มลูกศรย้าย viewport ไปรอบ ๆ แคนวาส ก็แก้ได้
      สำหรับวัตถุที่ลากได้ งานคือการเปลี่ยนลำดับ หากต้องการให้เข้าถึงได้ ก็ให้จัดเตรียมวิธีทางเลือกในการเปลี่ยนลำดับ เช่น ใช้ TAB วนโฟกัสระหว่างวัตถุต่าง ๆ ใช้ปุ่มเลือกวัตถุที่โฟกัสอยู่ จากนั้นใช้ปุ่มลูกศรย้ายตัวอย่างก่อนลากไปยังตำแหน่งที่ถูกต้องที่ใกล้ที่สุดในทิศทางนั้น
    • โดยมากคำตอบจะใกล้เคียงกับ “ไม่ทำแบบนั้น” UI ควรมี ทางเลือกแทนการลาก เพื่อให้คนที่ใช้อุปกรณ์ชี้ตำแหน่งหรืออุปกรณ์สัมผัสไม่ได้ ก็ยังโต้ตอบกับหน้าได้
      กล่าวคือ อย่าพยายามยัดการรองรับคีย์บอร์ดเข้าไปใน implementation ของการลาก แต่ควรเพิ่มฟังก์ชันคีย์บอร์ดที่มีความหมาย แยกจากฟีเจอร์ลาก
    • นั่นน่าจะกลายเป็นวัตถุที่เคลื่อนย้ายได้ และดูเหมือนจะสร้างต่อจาก implementation นี้ได้ง่าย
    • ใช้ TAB / Space / Enter เพื่อเลือกไอเท็ม แล้วใช้ Ctrl + ปุ่มลูกศรเพื่อขยับองค์ประกอบที่ “กำลังถูกลาก” ก็พอ แน่นอนว่ากดปุ่มหนึ่งครั้งจะขยับได้ทีละหนึ่งบล็อกเท่านั้น
  • ดีเลย ช่วงต้นสัปดาห์นี้ผมกำลัง implement การลากองค์ประกอบ SVG ในแอป Vue อยู่พอดี และต้องค้นพบด้วยตัวเองแทบทุกอย่างที่ผู้เขียนอธิบาย ตามลำดับเดียวกันด้วย สุดท้ายก็ได้คอมโพเนนต์ที่แทบเหมือนกัน
    แต่ผมเขียนเป็นยูทิลิตี composable ชื่อ useDragging แทนที่จะใช้ functional component

    • เยี่ยมเลย ผมเคยลองทำเป็น component ที่มี directive กับ slot แล้ว แต่ยังไม่เคยลองแบบ composable เพราะยังไม่มั่นใจว่าควรตั้งค่า event handler ด้วยวิธีนั้นอย่างไร ประสบการณ์กับ composable ยังไม่มากนัก