JavaScript-如何操作可拖曳的元素

新的酷東東。

簡述

拖曳可以應用的場景有很多,像是:

  • 用拖拉的方式來上傳檔案
  • 用拖拉的方式來交換元素位置
  • 把東西從 A 拖拉到 B 點(例如驗證 or 圖形介面)

所以覺得這還蠻值得學的,可以運用的地方有很多。

首先來一個 MDN 的範例,我們要做的是像這樣的效果:

basic-example

如果你想直接看原始碼的話可以到 Codepen 上看,接下來會逐步介紹相關的 API 和思路。

這邊先補充一個概念,如果要讓一個元素是「draggable(可拖曳)」 的話,要加上 draggable="true" 這個屬性:

1
<div class="ball" draggable="true"></div>

如果沒有加的話預設都是無法拖曳的,所以一定要記得哦。

接著來解釋這邊用到的 API,總共有三個:

  • dragstart
  • dragover
  • drop

dragstart

當元素開始被拖曳的時候會觸發。

這邊是利用了這個 function 來取得「目前拖曳的元素是誰」:

1
2
3
4
let dragItem = null
document.addEventListener('dragstart', (e) => {
dragItem = e.target
})

之所以要存起來是因為我們希望在「某個時機點」把它插到其他 DOM 元素上,所以才需要這個 reference。

dragover

當有「draggable(元素 / 檔案)」的東西被拖曳進來時觸發,而且是連續觸發。

這邊通常可以做 dragging 的醒目效果,不過更重要的是 e.preventDefault,因為它可以:

  • 讓元素從「不能被 drop」變成「可以被 drop」的狀態。
  • 讓元素從「不能被 drop」變成「可以被 drop」的狀態。
  • 讓元素從「不能被 drop」變成「可以被 drop」的狀態。

以目前的範例來說不是很好懂,所以這邊會用另一個範例解釋。

要看一個元素是否能 drop 可以從 cursor 有沒有顯示 + 號 來判斷,像這樣:

allow-drop

灰色區塊因為有加上 e.preventDefault,所以當它被紅球 dragover 時就會顯示 + 號,代表這裡是可以 drop 的。

詳細可以自己到 Codepen 上玩玩看。

drop

當「draggable(元素 / 檔案)」的東西放掉的時候觸發。

回到原本的例子,我們希望在「紅球放掉的時候」來更新他的位置,所以寫了這樣的判斷:

1
2
3
4
5
6
7
8
9
10
11
12
13
document.addEventListener('drop', (e) => {
// 停用預設的 download 行為(重要)
e.preventDefault()
// drop 到的那個元素
const targetElement = e.target
// 判斷 target 是否正確
if (targetElement.classList.contains('dropzone')) {
// 移除
dragItem.parentNode.removeChild(dragItem)
// 插入
e.target.append(dragItem)
}
})

簡單來說只要紅球 drop 的元素有 dropzone 這個 calss 就會「更新位置」,所以大致的思路就是這樣。

不過這邊想特別提一件事情,就是要注意「 addEventListener 是綁在誰身上?」

會提這個是因為當「綁定的對象不同時思路也會不同」。以剛剛的範例來看都都是綁在 document 身上,所以像是 drop 不管是在哪裡發生的都會被觸發。

可是如果現在 addEventListener 是掛在 .target 身上的話就不一樣了,這樣就只有在「東西被 drop 到它身上時」才會觸發這個事件(有點像是第一人稱跟第三人稱的差別),這一段如果不懂的話建議自己多寫幾個範例來玩玩看,應該比較能體會我想表達的意思。

製作可拖曳的列表

draggable-list

這個算是蠻常見的需求,所以能練習一下。至於思路都寫在註解中了,不懂的話試著自己做做看就懂了。

可能會比較有疑問的地方應該是 e.dataTransfer 的部分,這裡稍微解釋一下。簡單來說就是讓你「幫被拖曳的元素儲存資訊」,這樣子在做判斷時才有相關的 reference,詳細的用法可以參考 MDN,或這篇 文章

JavaScript + jQuery

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<body>
<ul class="list">
<li>One</li>
<li>Two</li>
<li>Three</li>
<li>Four</li>
</ul>

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script>
// 取得所有 li
const items = document.querySelectorAll('li')
// 設定 property 和 listener
items.forEach((li) => {
$(li).prop('draggable', true)
li.addEventListener('dragstart', dragStart)
li.addEventListener('drop', dropped)
li.addEventListener('dragover', (e) => e.preventDefault())
})

function dragStart(e) {
// 取得 index
const index = $(e.target).index()
// 設定被拖曳元素的資訊,之後可透過 getData() 來取得
e.dataTransfer.setData('text/plain', index)
}

function dropped(e) {
e.preventDefault()
// 記得轉成數字
const oldIndex = parseInt(e.dataTransfer.getData('text/plain'), 10)
const target = $(e.target)
const newIndex = $(e.target).index()

// remove orignal element
// 1. 找到 parent
// 2. 把第 oldIndex 個 child 給移除
// 3. 把回傳值儲存(被刪除的元素)
const dropped = $(this).parent().children().eq(oldIndex).remove()

// 新位置(目標)比原本還後面就插到他(新位置)後面,以此類推
oldIndex < newIndex ? target.after(dropped) : target.before(dropped)
}
</script>
</body>

原始碼可到 CodePen 參考。

其實最關鍵的地方就是 dragStart 時儲存「原始位置」,接著 dropped 時取得「新的位置」,最後再根據「新 / 舊位置」來判斷要放到那個位置,簡單來說就是這樣吧。

React

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export default function App() {
const [items, setItems] = useState(['One', 'Two', 'Three', 'Four'])

return (
<ul className='list'>
{items.map((item, index) => (
<li
draggable='true'
key={item}
onDragOver={(e) => e.preventDefault()}
onDragStart={(e) => {
// 儲存 drag 時的位置
e.dataTransfer.setData('text/plain', index)
}}
onDrop={(e) => {
const oldIndex = parseInt(e.dataTransfer.getData('text/plain'), 10)
// drop 的位置
let targetIndex = index
const newItems = [...items]
// remove the old one
const removedItem = newItems.splice(oldIndex, 1)
// insert to target index
newItems.splice(targetIndex, 0, ...removedItem)
setItems(newItems)
}}
>
{item}
</li>
))}
</ul>
)
}

原始碼可到 CodePen 來看。

React 相對來說會直覺一些,畢竟只要專注在「資料」就好,不過要留意一下 splice 的部分。第一個參數可以想成是「我希望元素放到的位置」,這樣子會比較好理解一點。

例如說我想把某個元素放到第一個位置,那就會用 splice(0, 0, item),第二個的話就是 splice(1, 0, item),以此類推。就不用像寫 jQuery 時還要多判斷「插到前面還是後面」的這一步。

可拖曳檔案(React)

做完上面的練習後應該就能做出這樣的功能:

draggable-file

原始碼可到 CodePen 參考

主要只是多了「上傳檔案」的功能,背後的拖曳邏輯其實都跟剛剛差不多,所以這邊就直接附上 code 不解釋太多了,可以參考註解邊練習看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
export default function App() {
const [uploadFiles, setUploadFiles] = useState([])

return (
<>
<div className='upload'>
<p className='upload-title'>Link path</p>
<div
className='upload-area'
// allow drop
onDragOver={(e) => e.preventDefault()}
// receive file (拖曳進來的檔案)
onDrop={(e) => {
// prevent dowload
e.preventDefault()
if (e.dataTransfer.files.length > 0) {
setUploadFiles(Array.from(e.dataTransfer.files))
}
}}
>
<div className='upload-area-list'>
<ul className='list'>
{uploadFiles.map((file, index) => (
<li
draggable='true'
key={file.name}
onDragOver={(e) => {
// allow drop
e.preventDefault()
// stop bubbling to parent (.upload-area)
e.stopPropagation()
}}
onDragStart={(e) => e.dataTransfer.setData('text/plain', index)}
onDrop={(e) => {
// prevent dowload
e.preventDefault()
// stop bubbling to parent (.upload-area)
e.stopPropagation()
const oldIndex = parseInt(e.dataTransfer.getData('text/plain'), 10)

// 避免從桌面拖曳檔案到 li 上的情況
if (!isNaN(oldIndex)) {
let targetIndex = index
// 因為是 blob 所以得用這種方式 copy
const newFiles = uploadFiles.map((file) => new File([file], file.name))
const removedBackup = newFiles[oldIndex]
// remove the old one
newFiles.splice(oldIndex, 1)
// insert to target index
newFiles.splice(targetIndex, 0, removedBackup)
setUploadFiles(newFiles)
}
}}
>
<span className='file-name'>
0{index + 1}. {file.name}
</span>
<span
onClick={() =>
setUploadFiles((prev) => prev.filter((f) => f.name !== file.name))
}
className='material-icons material-icons-close'
>
close
</span>
</li>
))}
</ul>
</div>
<button className='upload-area-button'>
<span className='material-icons'>file_upload</span>
<input
type='file'
multiple
onChange={(e) => setUploadFiles(Array.from(e.target.files))}
/>
</button>
</div>
</div>
<button className='btn-save' onClick={() => console.log(uploadFiles)}>
Save
</button>
</>
)
}

這邊要特別注意 e.stopPropagation 的功能,如果沒有在該加的地方加上的話就有可能遇到「事件從 children 冒泡到 parent」的問題。

像是假設我在 <li> 觸發了 drop 事件,沒有取消傳遞的話就會冒泡到 .upload-area 的位置上,所以就會變成這樣:

  • 執行 li 的 event callback,把位置交換
  • 執行 .upload-area 的 event callback,更新 state

這就跟我們預期的不一樣了,我們只希望li 的 callback 被執行而已。

所以這邊要特別留意事件傳遞機制的問題,不熟的話可以參考我寫的 阻止事件傳遞 stopPropagation

雖然你也可以暴力解,通通加上 stopPropagationpreventDefault,但我覺得去理解他們實際到底在「預防什麼」會更好。

Ant Design-Upload 相關的 Props Vue-Router Guard
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×