v1.9.5 - performance enhancement

This commit is contained in:
LINING-PC\lining
2026-03-07 19:19:13 +08:00
parent c6a7edf502
commit 389fe60a8f
9 changed files with 269 additions and 41 deletions
+8
View File
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.9.5] - 2026-03-07
### Fixed
- 🔧 优化:优化算法,提升插件性能,更好兼容移动设备的访问
- 🔧 优化:来自Github一位用户的共同探讨,带来此次性能的飞跃,同时他也提供了另外一种应用层的优化方案,请参考: <a href=\"https://github.com/nelson820125/jordium-gantt-vue3/discussions/16\">discussion #16</a>
- 🔧 Optimized: Algorithm optimizations for improved plugin performance and better compatibility with mobile device access
- 🔧 Optimized: Collaborative discussions with a Github user led to this performance leap, and they also provided another application-level optimization方案, please refer to: <a href=\"https://github.com/nelson820125/jordium-gantt-vue3/discussions/16\">discussion #16</a>
## [1.9.4] - 2026-03-06 ## [1.9.4] - 2026-03-06
### Fixed ### Fixed
+12
View File
@@ -606,5 +606,17 @@
"🔧 Fixed: Vulnerabilities fixed, jspdf added dependency dompurify for HTML XSS sanitization", "🔧 Fixed: Vulnerabilities fixed, jspdf added dependency dompurify for HTML XSS sanitization",
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to jwuxin@gitee for their use and feedback</span>" "<span style=\"font-weight: bold; color: #f00;\">Special thanks to jwuxin@gitee for their use and feedback</span>"
] ]
},
{
"version": "1.9.5",
"date": "2026-03-07",
"notes": [
"🔧 优化:优化算法,提升插件性能,更好兼容移动设备的访问",
"🔧 优化:来自Github一位用户的共同探讨,是带来此次性能飞跃的关键,同时他也提供了另外一种应用层的优化方案,请参考: <a href=\"https://github.com/nelson820125/jordium-gantt-vue3/discussions/16\">discussion #16</a>",
"<span style=\"font-weight: bold; color: #f00;\">特别感谢来自eldrift@github的使用与反馈</span>",
"🔧 Optimized: Algorithm optimizations for improved plugin performance and better compatibility with mobile device access",
"🔧 Optimized: Collaborative discussions with a Github user led to this performance leap, and they also provided another application-level optimization方案, please refer to: <a href=\"https://github.com/nelson820125/jordium-gantt-vue3/discussions/16\">discussion #16</a>",
"<span style=\"font-weight: bold; color: #f00;\">Special thanks to eldritf@github for their use and feedback</span>"
]
} }
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jordium-gantt-vue3", "name": "jordium-gantt-vue3",
"version": "1.9.4", "version": "1.9.5",
"type": "module", "type": "module",
"main": "npm-package/dist/jordium-gantt-vue3.cjs.js", "main": "npm-package/dist/jordium-gantt-vue3.cjs.js",
"module": "npm-package/dist/jordium-gantt-vue3.es.js", "module": "npm-package/dist/jordium-gantt-vue3.es.js",
+23
View File
@@ -33,6 +33,8 @@ interface Props {
verticalLines?: VerticalLine[] verticalLines?: VerticalLine[]
showVerticalLines?: boolean showVerticalLines?: boolean
// 主题模式通过inject获取,不再需要props // 主题模式通过inject获取,不再需要props
// 滚动优化:垂直滚动时跳过 canvas 重绘,等滚动停止后一次性重绘
isScrolling?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -40,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
showVerticalLines: true, showVerticalLines: true,
offsetLeft: 0, offsetLeft: 0,
offsetTop: 0, offsetTop: 0,
isScrolling: false,
}) })
// 通过inject获取主题 // 通过inject获取主题
@@ -54,6 +57,8 @@ const canvasManager = new CanvasContextManager()
// requestAnimationFrame 防抖控制 // requestAnimationFrame 防抖控制
let rafId: number | null = null let rafId: number | null = null
let pendingRedraw = false let pendingRedraw = false
// 滚动期间跳过重绘,滚动停止后补一次
let pendingRedrawOnScrollStop = false
// 当前主题(用于分隔线颜色) // 当前主题(用于分隔线颜色)
const isDarkTheme = computed(() => injectedTheme.value === 'dark') const isDarkTheme = computed(() => injectedTheme.value === 'dark')
@@ -406,8 +411,15 @@ const drawArrowOptimized = (
/** /**
* 使用 requestAnimationFrame 优化的重绘调度器 * 使用 requestAnimationFrame 优化的重绘调度器
* 合并多个连续的重绘请求为单次绘制 * 合并多个连续的重绘请求为单次绘制
* 垂直滚动期间跳过重绘,等滚动停止后补绘一次(避免每帧重绘 canvas)
*/ */
const scheduleRedraw = () => { const scheduleRedraw = () => {
// 滚动期间:延迟至滚动停止后再重绘
if (props.isScrolling) {
pendingRedrawOnScrollStop = true
return
}
if (pendingRedraw) { if (pendingRedraw) {
// 已有待处理的重绘请求,跳过 // 已有待处理的重绘请求,跳过
return return
@@ -428,6 +440,17 @@ const scheduleRedraw = () => {
}) })
} }
// 监听滚动结束:滚动期间有待重绘时,在停止后立即补绘一次
watch(
() => props.isScrolling,
(scrolling) => {
if (!scrolling && pendingRedrawOnScrollStop) {
pendingRedrawOnScrollStop = false
scheduleRedraw()
}
},
)
// 监听相关状态变化,自动重绘 Canvas // 监听相关状态变化,自动重绘 Canvas
watch( watch(
[ [
+1
View File
@@ -3694,6 +3694,7 @@ const handleAnchorDragEnd = (anchorEvent: { taskId: number; type: 'predecessor'
position: absolute; position: absolute;
border-radius: 4px; border-radius: 4px;
user-select: none; user-select: none;
touch-action: none; /* JS 完全接管拖拽手势,不影响父层水平滚动 */
/* 阴影通过JS动态设置,使用TaskBar的颜色 */ /* 阴影通过JS动态设置,使用TaskBar的颜色 */
transition: transition:
box-shadow 0.2s, box-shadow 0.2s,
@@ -142,6 +142,9 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
// ==================== 滚动同步事件 ==================== // ==================== 滚动同步事件 ====================
// 滚动同步标志位:防止 Timeline → TaskList 写入后反向再次派发(2跳链路优化)
let _isSyncingScrollFromTimeline = false
const handleTaskListScroll = (event: Event) => { const handleTaskListScroll = (event: Event) => {
const target = event.target as HTMLElement const target = event.target as HTMLElement
if (!target) return if (!target) return
@@ -149,6 +152,9 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
const scrollTop = target.scrollTop const scrollTop = target.scrollTop
taskListScrollTop.value = scrollTop taskListScrollTop.value = scrollTop
// 由 Timeline → TaskList 同步写入触发时,不再反向派发,避免 2 跳循环
if (_isSyncingScrollFromTimeline) return
// 滚动时关闭所有右键菜单 // 滚动时关闭所有右键菜单
window.dispatchEvent(new CustomEvent('close-all-taskbar-menus')) window.dispatchEvent(new CustomEvent('close-all-taskbar-menus'))
@@ -166,7 +172,11 @@ export function useTaskListEventHandlers(options: TaskListEventHandlersOptions)
taskListScrollTop.value = scrollTop taskListScrollTop.value = scrollTop
if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) { if (taskListBodyElement && Math.abs(taskListBodyElement.scrollTop - scrollTop) > 1) {
// 设置标志位:当前是由 Timeline 触发的同步写入
// 防止 handleTaskListScroll 被触发后反向再次派发事件(2跳链路)
_isSyncingScrollFromTimeline = true
taskListBodyElement.scrollTop = scrollTop taskListBodyElement.scrollTop = scrollTop
Promise.resolve().then(() => { _isSyncingScrollFromTimeline = false })
} }
} }
@@ -1,5 +1,6 @@
import { onMounted, onUnmounted, type Ref } from 'vue' import { onMounted, onUnmounted, type Ref } from 'vue'
import type { Task } from '../../../../models/classes/Task' import type { Task } from '../../../../models/classes/Task'
import { registerDragOver, unregisterDragOver } from '../../../../utils/dragOverRegistry'
/** /**
* TaskRow * TaskRow
@@ -98,27 +99,25 @@ export function useTaskRowEventHandlers(
} }
} }
// 处理全局拖拽悬停事件 // 处理全局拖拽悬停事件(通过 dragOverRegistry 委托,不再直接监听 window)
const handleTaskRowDragOver = (event: CustomEvent) => { // 优化:从每行独立注册 window 监听器改为全局单一监听器 + Map 直接查找 O(1)
const handleTaskRowDragOver = (mouseEvent: MouseEvent) => {
if (!enableDrag.value || !taskRowRef.value || !dragOver) return if (!enableDrag.value || !taskRowRef.value || !dragOver) return
dragOver(task.value, taskRowRef.value, mouseEvent)
const { taskId, event: mouseEvent } = event.detail
if (taskId === task.value.id) {
dragOver(task.value, taskRowRef.value, mouseEvent)
}
} }
// 生命周期钩子 - 注册事件监听器 // 生命周期钩子 - 注册事件监听器
onMounted(() => { onMounted(() => {
window.addEventListener('splitter-drag-start', handleSplitterDragStart) window.addEventListener('splitter-drag-start', handleSplitterDragStart)
window.addEventListener('splitter-drag-end', handleSplitterDragEnd) window.addEventListener('splitter-drag-end', handleSplitterDragEnd)
window.addEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener) // 使用 dragOverRegistry 委托注册,避免每个 TaskRow 各自占用一个 window 监听器
registerDragOver(task.value.id, handleTaskRowDragOver)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('splitter-drag-start', handleSplitterDragStart) window.removeEventListener('splitter-drag-start', handleSplitterDragStart)
window.removeEventListener('splitter-drag-end', handleSplitterDragEnd) window.removeEventListener('splitter-drag-end', handleSplitterDragEnd)
window.removeEventListener('task-row-drag-over', handleTaskRowDragOver as EventListener) unregisterDragOver(task.value.id)
}) })
return { return {
+164 -31
View File
@@ -178,6 +178,11 @@ const showConflicts = inject<ComputedRef<boolean>>('gantt-show-conflicts', compu
const ROW_HEIGHT = 51 // 51px (50px + 1px border) const ROW_HEIGHT = 51 // 51px (50px + 1px border)
const VERTICAL_BUFFER = 5 // const VERTICAL_BUFFER = 5 //
const timelineBodyScrollTop = ref(0) // const timelineBodyScrollTop = ref(0) //
// TaskList Timeline 2
let _isSyncingScrollFromTaskList = false
// GanttLinks canvas
const isTimelineScrolling = ref(false)
let _scrollIdleTimer: ReturnType<typeof setTimeout> | null = null
const timelineBodyHeight = ref(0) // const timelineBodyHeight = ref(0) //
// v1.9.9 GanttChart // v1.9.9 GanttChart
@@ -1745,6 +1750,74 @@ const visibleResources = computed(() => {
})) }))
}) })
// TaskBar
// getBoundingClientRect reflow
const TASK_VIEW_BATCH_SIZE = 3 // 3
let _taskBatchRafId: number | null = null
let _taskBatchQueue: { task: Task; originalIndex: number }[] = []
// P2 ID number | string Set<number> ID
const _taskRenderedIds = new Set<number | string>()
const taskRenderedItems = shallowRef<{ task: Task; originalIndex: number }[]>([])
watch(
[visibleTasks, viewMode] as const,
([newVisible, mode]) => {
if (mode !== 'task') {
//
if (_taskBatchRafId !== null) { cancelAnimationFrame(_taskBatchRafId); _taskBatchRafId = null }
_taskBatchQueue = []
_taskRenderedIds.clear()
taskRenderedItems.value = []
return
}
// Set<number | string> _taskRenderedIds ID number
const newIds = new Set<number | string>(newVisible.map(t => t.task.id))
// P1 ID taskBarPositions
// tasks watch
const removedIds: (number | string)[] = []
_taskRenderedIds.forEach(id => {
if (!newIds.has(id)) {
_taskRenderedIds.delete(id)
removedIds.push(id)
}
})
if (removedIds.length > 0) {
const newPositions = { ...taskBarPositions.value }
for (const id of removedIds) {
delete newPositions[id as number]
}
taskBarPositions.value = newPositions
}
//
const toAdd = newVisible.filter(t => !_taskRenderedIds.has(t.task.id))
//
if (_taskBatchRafId !== null) { cancelAnimationFrame(_taskBatchRafId); _taskBatchRafId = null }
_taskBatchQueue = toAdd
//
const firstBatch = _taskBatchQueue.splice(0, TASK_VIEW_BATCH_SIZE)
firstBatch.forEach(t => _taskRenderedIds.add(t.task.id))
taskRenderedItems.value = newVisible.filter(t => _taskRenderedIds.has(t.task.id))
//
if (_taskBatchQueue.length > 0) {
const step = () => {
const batch = _taskBatchQueue.splice(0, TASK_VIEW_BATCH_SIZE)
if (batch.length === 0) { _taskBatchRafId = null; return }
batch.forEach(t => _taskRenderedIds.add(t.task.id))
taskRenderedItems.value = visibleTasks.value.filter(t => _taskRenderedIds.has(t.task.id))
_taskBatchRafId = _taskBatchQueue.length > 0 ? requestAnimationFrame(step) : null
}
_taskBatchRafId = requestAnimationFrame(step)
}
},
{ immediate: true },
)
// v1.9.6 Sprint2(P1+P4) - TaskBar205 // v1.9.6 Sprint2(P1+P4) - TaskBar205
const RESOURCE_BATCH_SIZE = 5 // 4: 205 const RESOURCE_BATCH_SIZE = 5 // 4: 205
const FRAME_TIME_BUDGET = 8 // 8msTaskBar const FRAME_TIME_BUDGET = 8 // 8msTaskBar
@@ -3556,12 +3629,18 @@ const canvasHeight = ref(0)
const canvasOffsetLeft = ref(0) // Canvas const canvasOffsetLeft = ref(0) // Canvas
const canvasOffsetTop = ref(0) const canvasOffsetTop = ref(0)
// Canvas // Canvas OOM
// // / updateSvgSize()
// - 5000: (~30MB) // - = clamp( × 3, 2000, 8000) 2 + 线
// - 10000: (~60MB) 10 2 // - = clamp( × 3, 1200, 4000) 3 100MB15MB
const SAFE_CANVAS_WIDTH = 5000 // function computeSafeCanvasDimensions(): { width: number; height: number } {
const SAFE_CANVAS_HEIGHT = 5000 const vw = Math.max(timelineContainerWidth.value, 300) // 0
const vh = Math.max(timelineBodyHeight.value, 300)
return {
width: Math.min(Math.max(vw * 3, 2000), 8000),
height: Math.min(Math.max(vh * 3, 1200), 4000),
}
}
function updateSvgSize() { function updateSvgSize() {
if (bodyContentRef.value) { if (bodyContentRef.value) {
@@ -3574,8 +3653,11 @@ function updateSvgSize() {
const scrollLeft = timelineScrollLeft.value const scrollLeft = timelineScrollLeft.value
const scrollTop = timelineBodyScrollTop.value const scrollTop = timelineBodyScrollTop.value
// // Canvas
const { width: SAFE_CANVAS_WIDTH, height: SAFE_CANVAS_HEIGHT } = computeSafeCanvasDimensions()
// //
// Canvas 使 offsetLeft // Canvas 使 offsetLeft
canvasWidth.value = SAFE_CANVAS_WIDTH canvasWidth.value = SAFE_CANVAS_WIDTH
// Canvas // Canvas
@@ -3614,6 +3696,12 @@ function updateSvgSize() {
} }
} }
// 2 handleBarMounted TaskBar
// N TaskBar N taskBarPositions spread + N GanttLinks RAF
// N TaskBar 1 + 1 GanttLinks
const _pendingBarUpdates: Record<number, { left: number; top: number; width: number; height: number }> = {}
let _barUpdateScheduled = false
function handleBarMounted(payload: { function handleBarMounted(payload: {
id: number id: number
left: number left: number
@@ -3622,23 +3710,52 @@ function handleBarMounted(payload: {
height: number height: number
}) { }) {
if (!bodyContentRef.value) return if (!bodyContentRef.value) return
const baseRect = bodyContentRef.value.getBoundingClientRect() // 使 cachedBodyRect reflow
// bodyContent // bodyRectInvalidated true
// 使 shallowRef if (!cachedBodyRect || bodyRectInvalidated) {
taskBarPositions.value = { cachedBodyRect = bodyContentRef.value.getBoundingClientRect()
...taskBarPositions.value, bodyRectCacheTime = Date.now()
[payload.id]: { bodyRectInvalidated = false
left: payload.left - baseRect.left, }
top: payload.top - baseRect.top, const baseRect = cachedBodyRect
width: payload.width, // pending
height: payload.height, _pendingBarUpdates[payload.id] = {
}, left: payload.left - baseRect.left,
top: payload.top - baseRect.top,
width: payload.width,
height: payload.height,
}
if (!_barUpdateScheduled) {
// flush_pendingBarUpdates
// GanttLinks isScrolling=false
if (isTimelineScrolling.value) return
_barUpdateScheduled = true
//
Promise.resolve().then(() => {
_barUpdateScheduled = false
// GanttLinks
taskBarPositions.value = { ...taskBarPositions.value, ..._pendingBarUpdates }
for (const key in _pendingBarUpdates) {
delete _pendingBarUpdates[key as unknown as number]
}
setTimeout(() => updateSvgSize(), 200)
})
} }
setTimeout(() => {
updateSvgSize()
}, 200)
} }
// flush bar
watch(isTimelineScrolling, (scrolling) => {
if (scrolling) return
if (Object.keys(_pendingBarUpdates).length === 0) return
_barUpdateScheduled = false
taskBarPositions.value = { ...taskBarPositions.value, ..._pendingBarUpdates }
for (const key in _pendingBarUpdates) {
delete _pendingBarUpdates[key as unknown as number]
}
setTimeout(() => updateSvgSize(), 200)
})
// TaskBar / // TaskBar /
const handleTaskBarDragEnd = (updatedTask: Task) => { const handleTaskBarDragEnd = (updatedTask: Task) => {
// dataSource // dataSource
@@ -3859,8 +3976,12 @@ const handleTaskListVerticalScroll = (event: CustomEvent) => {
debouncedUpdateCanvasPosition() debouncedUpdateCanvasPosition()
if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) { if (timelineBodyElement.value && Math.abs(timelineBodyElement.value.scrollTop - scrollTop) > 1) {
// 使1px // TaskList
// handleTimelineBodyScroll 2
_isSyncingScrollFromTaskList = true
timelineBodyElement.value.scrollTop = scrollTop timelineBodyElement.value.scrollTop = scrollTop
// scroll Promise.resolve
Promise.resolve().then(() => { _isSyncingScrollFromTaskList = false })
} }
} }
@@ -3877,11 +3998,22 @@ const handleTimelineBodyScroll = (event: Event) => {
// bodyRect 线 // bodyRect 线
bodyRectInvalidated = true bodyRectInvalidated = true
// GanttLinks canvas
isTimelineScrolling.value = true
if (_scrollIdleTimer !== null) clearTimeout(_scrollIdleTimer)
_scrollIdleTimer = setTimeout(() => {
_scrollIdleTimer = null
isTimelineScrolling.value = false
}, 150)
debouncedUpdateCanvasPosition() debouncedUpdateCanvasPosition()
// //
if (isDragging.value) return if (isDragging.value) return
// TaskList Timeline 2
if (_isSyncingScrollFromTaskList) return
// TaskList // TaskList
if (scrollTop >= 0) { if (scrollTop >= 0) {
window.dispatchEvent( window.dispatchEvent(
@@ -4736,19 +4868,19 @@ watch(
() => tasks.value, () => tasks.value,
newTasks => { newTasks => {
invalidateTaskDateRangeCache() invalidateTaskDateRangeCache()
// 使 for Set map // P2 task.id number string string
const currentTaskIds = new Set<number>() // 使 parseInt(taskIdStr) "res-1" NaN ID
const currentTaskIdStrs = new Set<string>()
for (const task of newTasks) { for (const task of newTasks) {
currentTaskIds.add(task.id) currentTaskIdStrs.add(String(task.id))
} }
// 使 for...in Object.keys().forEach()
// shallowRef // shallowRef
const newPositions: Record<number, any> = {} const newPositions: Record<number, any> = {}
for (const taskIdStr in taskBarPositions.value) { for (const taskIdStr in taskBarPositions.value) {
const taskId = parseInt(taskIdStr) // string Set parseInt ID NaN
if (currentTaskIds.has(taskId)) { if (currentTaskIdStrs.has(taskIdStr)) {
newPositions[taskId] = taskBarPositions.value[taskId] newPositions[taskIdStr as unknown as number] = taskBarPositions.value[taskIdStr as unknown as number]
} }
} }
taskBarPositions.value = newPositions taskBarPositions.value = newPositions
@@ -5270,6 +5402,7 @@ const handleAddSuccessor = (task: Task) => {
:hovered-task-id="hoveredTaskId" :hovered-task-id="hoveredTaskId"
:vertical-lines="monthFirstVerticalLines" :vertical-lines="monthFirstVerticalLines"
:show-vertical-lines="currentTimeScale === TimelineScale.WEEK" :show-vertical-lines="currentTimeScale === TimelineScale.WEEK"
:is-scrolling="isTimelineScrolling"
/> />
<!-- 连接线拖拽引导线 - 🚀 优化使用命令式 API RAF 直接调用 draw() --> <!-- 连接线拖拽引导线 - 🚀 优化使用命令式 API RAF 直接调用 draw() -->
@@ -5458,9 +5591,9 @@ const handleAddSuccessor = (task: Task) => {
<!-- 同时需要考虑左侧TaskList包含1px的bottom border --> <!-- 同时需要考虑左侧TaskList包含1px的bottom border -->
<div class="task-bar-container" :style="{ height: `${contentHeight}px` }"> <div class="task-bar-container" :style="{ height: `${contentHeight}px` }">
<div class="task-rows" :style="{ height: `${contentHeight}px` }"> <div class="task-rows" :style="{ height: `${contentHeight}px` }">
<!-- 任务视图使用虚拟滚动渲染可见任务 --> <!-- 任务视图使用虚拟滚动分批渲染可见任务taskRenderedItems 每帧限流 3 行新增-->
<div <div
v-for="{ task, originalIndex } in visibleTasks" v-for="{ task, originalIndex } in taskRenderedItems"
v-if="viewMode === 'task'" v-if="viewMode === 'task'"
:key="task.id" :key="task.id"
class="task-row" class="task-row"
+42
View File
@@ -0,0 +1,42 @@
/**
* dragOverRegistry -
*
* TaskRow window 'task-row-drag-over'
* CustomEvent 广O(n)
*
* + Map O(1)
* N 1
*/
type DragOverCallback = (mouseEvent: MouseEvent) => void
const _registry = new Map<number | string, DragOverCallback>()
function _handleGlobalDragOver(e: Event) {
const { taskId, event: mouseEvent } = (e as CustomEvent).detail
const handler = _registry.get(taskId)
if (handler) handler(mouseEvent)
}
/**
*
* window
*/
export function registerDragOver(taskId: number | string, handler: DragOverCallback): void {
const wasEmpty = _registry.size === 0
_registry.set(taskId, handler)
if (wasEmpty) {
window.addEventListener('task-row-drag-over', _handleGlobalDragOver)
}
}
/**
*
* window
*/
export function unregisterDragOver(taskId: number | string): void {
_registry.delete(taskId)
if (_registry.size === 0) {
window.removeEventListener('task-row-drag-over', _handleGlobalDragOver)
}
}