diff options
| author | lolka1333 <xtrafcyz@gmail.com> | 2026-05-05 18:27:49 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-05 18:27:49 +0300 |
| commit | 8177f6dc667f2edca66073e433baf5cff36cda41 (patch) | |
| tree | 7fddbec4848c3c0ab0fc39e3dc45703c368e9340 /web/html/component/aTableSortable.html | |
| parent | 77d94b25d054bd6cf7ace029571db9c58ae87fa9 (diff) | |
* ws/inbounds: realtime fixes + perf for 10k+ client inbounds
- hub: dedup, throttle, panic-restart, deadlock fix, race tests
- client: backoff cap + slow-retry instead of giving up
- broadcast: delta-only payload, count-based invalidate fallback
- filter: fix empty online list (Inbound has no .id, use dbInbound.toInbound)
- perf: O(N²)→O(N) traffic merge, bulk delete, /setEnable endpoint
- traffic: monotonic all_time + UI clamp + propagate in delta handler
- session: persist on update/logout (fixes logout-after-password-change)
- ui: protocol tags flex, traffic bar normalize
* Remove hub_test.go file
* fix: ws hub, inbound service, and frontend correctness
- propagate DelInbound error on disable path in SetInboundEnable
- skip empty emails in updateClientTraffics to avoid constraint violations
- use consistent IN ? clause, drop redundant ErrRecordNotFound guards
- Hub.Unregister: direct removeClient fallback when channel is full
- applyClientStatsDelta: O(1) email lookup via per-inbound Map cache
- WS payload size check: Blob.size instead of .length for real byte count
* fix: chunk large IN ? queries and fix IPv6 same-origin check
* fix: chunk large IN ? queries and fix IPv6 same-origin check
* fix: unify clientStats cache, throttle clarity, hub constants
* fix(ui): align traffic/expiry cell columns across all rows
* style(ui): redesign outbounds table for visual consistency
* style(ui): redesign routing table for visual consistency
* fix:
* fix:
* fix:
* fix:
* fix:
* fix: font
* refactor: simplify outbound tone functions for consistency and maintainability
---------
Co-authored-by: lolka1333 <test123@gmail.com>
Diffstat (limited to 'web/html/component/aTableSortable.html')
| -rw-r--r-- | web/html/component/aTableSortable.html | 424 |
1 files changed, 244 insertions, 180 deletions
diff --git a/web/html/component/aTableSortable.html b/web/html/component/aTableSortable.html index 925adbb5..fdbc247e 100644 --- a/web/html/component/aTableSortable.html +++ b/web/html/component/aTableSortable.html @@ -1,238 +1,302 @@ {{define "component/sortableTableTrigger"}} -<a-icon type="drag" class="sortable-icon" :style="{ cursor: 'move' }" @mouseup="mouseUpHandler" - @mousedown="mouseDownHandler" @click="clickHandler" /> +<a-icon type="drag" class="sortable-icon" + role="button" tabindex="0" + :aria-label="ariaLabel" + @pointerdown="onPointerDown" + @keydown="onKeyDown" /> {{end}} {{define "component/aTableSortable"}} <script> - const DRAGGABLE_ROW_CLASS = 'draggable-row'; - const findParentRowElement = (el) => { - if (!el || !el.tagName) { - return null; - } else if (el.classList.contains(DRAGGABLE_ROW_CLASS)) { - return el; - } else if (el.parentNode) { - return findParentRowElement(el.parentNode); - } else { - return null; - } - } + /** + * Sortable a-table — drag-to-reorder rows using Pointer Events. + * + * Why a rewrite: + * - Old impl set `draggable: true` on every row, which (a) broke text + * selection inside cells, (b) let HTML5 start a drag from anywhere on + * the row even when the state machine wasn't primed, producing + * "phantom drags" that didn't reorder anything. + * - HTML5 drag has no touch support on most mobile browsers and no + * keyboard fallback at all. + * - The drag-image hack cloned the entire table — slow on big lists. + * + * New design: + * - Only the explicit drag handle initiates a drag, via Pointer Events + * (one API for mouse + touch + pen). Rows are not draggable. + * - During drag, `data-source` is reordered live: the source row visually + * slides into the target slot and other rows shift around it. The live + * reorder IS the visual feedback — no separate floating preview. + * - On commit, emits `onsort(sourceIndex, targetIndex)` — same event name + * and signature as before, so existing call sites stay unchanged. + * - Keyboard support: the handle is focusable; ArrowUp / ArrowDown move + * the row by one; Escape cancels a pointer-drag in progress. + */ + const ROW_CLASS = 'sortable-row'; + Vue.component('a-table-sortable', { data() { return { - sortingElementIndex: null, - newElementIndex: null, + // null when idle. While dragging: + // { sourceIndex, targetIndex, pointerId, sourceKey } + drag: null, }; }, props: { - 'data-source': { - type: undefined, - required: false, - }, - 'customRow': { - type: undefined, - required: false, - } + 'data-source': { type: undefined, required: false }, + 'customRow': { type: undefined, required: false }, + 'row-key': { type: undefined, required: false }, }, inheritAttrs: false, provide() { - const sortable = {} - Object.defineProperty(sortable, "setSortableIndex", { + const sortable = {}; + // Methods exposed to the trigger child via inject. Defined as getters + // so `this` binds to the component instance, not the plain object. + Object.defineProperty(sortable, 'startDrag', { enumerable: true, - get: () => this.setCurrentSortableIndex, + get: () => this.startDrag, }); - Object.defineProperty(sortable, "resetSortableIndex", { + Object.defineProperty(sortable, 'moveByKeyboard', { enumerable: true, - get: () => this.resetSortableIndex, + get: () => this.moveByKeyboard, }); - return { - sortable, - } + return { sortable }; }, - render: function(createElement) { - return createElement('a-table', { - class: { - 'ant-table-is-sorting': this.isDragging(), - }, - props: { - ...this.$attrs, - 'data-source': this.records, - customRow: (record, index) => this.customRowRender(record, index), - }, - on: this.$listeners, - nativeOn: { - drop: (e) => this.dropHandler(e), - }, - scopedSlots: this.$scopedSlots, - locale: { - filterConfirm: `{{ i18n "confirm" }}`, - filterReset: `{{ i18n "reset" }}`, - emptyText: `{{ i18n "noData" }}` - } - }, this.$slots.default, ) - }, - created() { - this.$memoSort = {}; + beforeDestroy() { + this.detachPointerListeners(); }, methods: { - isDragging() { - const currentIndex = this.sortingElementIndex; - return currentIndex !== null && currentIndex !== undefined; - }, - resetSortableIndex(e, index) { - this.sortingElementIndex = null; - this.newElementIndex = null; - this.$memoSort = {}; - }, - setCurrentSortableIndex(e, index) { - this.sortingElementIndex = index; + isDragging() { return this.drag !== null; }, + // Resolve the row key for a record. Used to identify the source row + // even after data-source is reordered live during drag. + keyOf(record, fallback) { + const rk = this.rowKey; + if (typeof rk === 'function') return rk(record); + if (typeof rk === 'string') return record && record[rk]; + return fallback; }, - dragStartHandler(e, index) { - if (!this.isDragging()) { - e.preventDefault(); - return; + startDrag(e, sourceIndex) { + // Primary button only (mouse left / first touch). + if (e.button != null && e.button !== 0) return; + e.preventDefault(); + const record = this.dataSource && this.dataSource[sourceIndex]; + this.drag = { + sourceIndex, + targetIndex: sourceIndex, + pointerId: e.pointerId, + sourceKey: this.keyOf(record, sourceIndex), + }; + // Capture the pointer so move/up keep firing even if the cursor leaves + // the icon. Try/catch because some older browsers throw on capture. + if (e.target && typeof e.target.setPointerCapture === 'function' && e.pointerId != null) { + try { e.target.setPointerCapture(e.pointerId); } catch (_) {} } - const hideDragImage = this.$el.cloneNode(true); - hideDragImage.id = "hideDragImage-hide"; - hideDragImage.style.opacity = 0; - e.dataTransfer.setDragImage(hideDragImage, 0, 0); + this.attachPointerListeners(); }, - dragStopHandler(e, index) { - const hideDragImage = document.getElementById('hideDragImage-hide'); - if (hideDragImage) hideDragImage.remove(); - this.resetSortableIndex(e, index); + attachPointerListeners() { + this._onMove = (ev) => this.onPointerMove(ev); + this._onUp = (ev) => this.onPointerUp(ev); + this._onCancel = (ev) => this.cancelDrag(ev); + document.addEventListener('pointermove', this._onMove, true); + document.addEventListener('pointerup', this._onUp, true); + document.addEventListener('pointercancel', this._onCancel, true); + document.addEventListener('keydown', this._onCancel, true); }, - dragOverHandler(e, index) { - if (!this.isDragging()) { - return; - } - e.preventDefault(); - const currentIndex = this.sortingElementIndex; - if (index === currentIndex) { - this.newElementIndex = null; - return; - } - const row = findParentRowElement(e.target); - if (!row) { - return; - } - const rect = row.getBoundingClientRect(); - const offsetTop = e.pageY - rect.top; - if (offsetTop < rect.height / 2) { - this.newElementIndex = Math.max(index - 1, 0); + detachPointerListeners() { + if (!this._onMove) return; + document.removeEventListener('pointermove', this._onMove, true); + document.removeEventListener('pointerup', this._onUp, true); + document.removeEventListener('pointercancel', this._onCancel, true); + document.removeEventListener('keydown', this._onCancel, true); + this._onMove = this._onUp = this._onCancel = null; + }, + onPointerMove(e) { + if (!this.drag) return; + if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return; + // Hit-test: find which row the pointer Y is inside (or closest to). + const rows = this.$el.querySelectorAll('tr.' + ROW_CLASS); + if (!rows.length) return; + const y = e.clientY; + const firstRect = rows[0].getBoundingClientRect(); + const lastRect = rows[rows.length - 1].getBoundingClientRect(); + let target = this.drag.targetIndex; + if (y < firstRect.top) { + target = 0; + } else if (y > lastRect.bottom) { + target = rows.length - 1; } else { - this.newElementIndex = index; + for (let i = 0; i < rows.length; i++) { + const rect = rows[i].getBoundingClientRect(); + if (y >= rect.top && y <= rect.bottom) { + target = i; + break; + } + } + } + if (target !== this.drag.targetIndex) { + this.drag = Object.assign({}, this.drag, { targetIndex: target }); } }, - dropHandler(e) { - if (this.isDragging()) { - this.$emit('onsort', this.sortingElementIndex, this.newElementIndex); + onPointerUp(e) { + if (!this.drag) return; + if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return; + this.commitDrag(); + }, + commitDrag() { + const d = this.drag; + this.detachPointerListeners(); + this.drag = null; + if (d && d.sourceIndex !== d.targetIndex) { + this.$emit('onsort', d.sourceIndex, d.targetIndex); } }, + cancelDrag(e) { + // Triggered by pointercancel and keydown handlers. For keydown, only + // act on Escape; otherwise let the event flow to other listeners. + if (e && e.type === 'keydown' && e.key !== 'Escape') return; + this.detachPointerListeners(); + this.drag = null; + }, + // Keyboard reorder: commit immediately by emitting onsort. No "preview" + // state needed since the move is one row up or down. + moveByKeyboard(direction, sourceIndex) { + const target = sourceIndex + direction; + if (target < 0 || target >= (this.dataSource || []).length) return; + this.$emit('onsort', sourceIndex, target); + }, customRowRender(record, index) { - const parentMethodResult = this.customRow?.(record, index) || {}; - const newIndex = this.newElementIndex; - const currentIndex = this.sortingElementIndex; - return { - ...parentMethodResult, - attrs: { - ...(parentMethodResult?.attrs || {}), - draggable: true, - }, - on: { - ...(parentMethodResult?.on || {}), - dragstart: (e) => this.dragStartHandler(e, index), - dragend: (e) => this.dragStopHandler(e, index), - dragover: (e) => this.dragOverHandler(e, index), - }, - class: { - ...(parentMethodResult?.class || {}), - [DRAGGABLE_ROW_CLASS]: true, - ['dragging']: this.isDragging() ? (newIndex === null ? index === currentIndex : index === newIndex) : - false, - }, - }; - } + const parent = (typeof this.customRow === 'function') + ? (this.customRow(record, index) || {}) + : {}; + const d = this.drag; + const isSource = d && this.keyOf(record, index) === d.sourceKey; + return Object.assign({}, parent, { + // CRITICAL: no `draggable: true`. Drag is initiated only by the + // handle icon. Leaves text-selection on cells working normally. + attrs: Object.assign({}, parent.attrs || {}), + class: Object.assign({}, parent.class || {}, { + [ROW_CLASS]: true, + 'sortable-source-row': !!isSource, + }), + }); + }, }, computed: { + // Render-data: dataSource with the source row spliced into targetIndex. + // When idle or when target equals source, returns the original list + // unchanged so Ant Design's table treats this as a stable reference. records() { - const newIndex = this.newElementIndex; - const currentIndex = this.sortingElementIndex; - if (!this.isDragging() || newIndex === null || currentIndex === newIndex) { - return this.dataSource; - } - if (this.$memoSort.newIndex === newIndex) { - return this.$memoSort.list; - } - let list = [...this.dataSource]; - list.splice(newIndex, 0, list.splice(currentIndex, 1)[0]); - this.$memoSort = { - newIndex, - list, - }; + const d = this.drag; + if (!d || d.sourceIndex === d.targetIndex) return this.dataSource; + const list = (this.dataSource || []).slice(); + const [item] = list.splice(d.sourceIndex, 1); + list.splice(d.targetIndex, 0, item); return list; - } - } + }, + }, + render(h) { + return h('a-table', { + class: { 'sortable-table': true, 'sortable-table-dragging': this.isDragging() }, + props: Object.assign({}, this.$attrs, { + 'data-source': this.records, + 'row-key': this.rowKey, + customRow: (record, index) => this.customRowRender(record, index), + locale: { + filterConfirm: `{{ i18n "confirm" }}`, + filterReset: `{{ i18n "reset" }}`, + emptyText: `{{ i18n "noData" }}`, + }, + }), + on: this.$listeners, + scopedSlots: this.$scopedSlots, + }, this.$slots.default); + }, }); + Vue.component('a-table-sort-trigger', { template: `{{template "component/sortableTableTrigger" .}}`, props: { - 'item-index': { - type: undefined, - required: false - } + 'item-index': { type: undefined, required: false }, }, inject: ['sortable'], + computed: { + ariaLabel() { + // Localised label is overkill for an internal a11y string; English is + // fine here and matches screen-reader expectations across locales. + return 'Drag to reorder row ' + (((this.itemIndex == null ? 0 : this.itemIndex) + 1)); + }, + }, methods: { - mouseDownHandler(e) { - if (this.sortable) { - this.sortable.setSortableIndex(e, this.itemIndex); + onPointerDown(e) { + if (this.sortable && this.sortable.startDrag) { + this.sortable.startDrag(e, this.itemIndex); } }, - mouseUpHandler(e) { - if (this.sortable) { - this.sortable.resetSortableIndex(e, this.itemIndex); + onKeyDown(e) { + if (!this.sortable || !this.sortable.moveByKeyboard) return; + if (e.key === 'ArrowUp') { + e.preventDefault(); + this.sortable.moveByKeyboard(-1, this.itemIndex); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + this.sortable.moveByKeyboard(+1, this.itemIndex); } }, - clickHandler(e) { - e.preventDefault(); - }, - } - }) + }, + }); </script> + <style> - @media only screen and (max-width: 767px) { - .sortable-icon { - display: none; - } + /* Drag handle — focusable, keyboard-accessible, touch-friendly hit area. + `touch-action: none` is critical: it tells the browser not to interpret + touch on the icon as a scroll/zoom gesture, so pointermove fires for + drag-tracking. Without it, mobile browsers eat the pointer events. */ + .sortable-icon { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: grab; + padding: 6px; + border-radius: 6px; + color: rgba(255, 255, 255, 0.5); + transition: background-color 0.15s ease, color 0.15s ease; + user-select: none; + touch-action: none; } - - .ant-table-is-sorting .draggable-row td { - background-color: #ffffff !important; + .sortable-icon:hover { + color: rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.06); } - - .dark .ant-table-is-sorting .draggable-row td { - background-color: var(--dark-color-surface-100) !important; + .sortable-icon:active { cursor: grabbing; } + .sortable-icon:focus-visible { + outline: 2px solid #008771; + outline-offset: 2px; } - .ant-table-is-sorting .dragging td { - background-color: rgb(232 244 242) !important; - color: rgba(0, 0, 0, 0.3); + .light .sortable-icon { color: rgba(0, 0, 0, 0.45); } + .light .sortable-icon:hover { + color: rgba(0, 0, 0, 0.85); + background: rgba(0, 0, 0, 0.05); } - .dark .ant-table-is-sorting .dragging td { - background-color: var(--dark-color-table-hover) !important; - color: rgba(255, 255, 255, 0.3); + /* While dragging: the source row gets a soft green wash so the user can + track which row is being moved. Other rows transition smoothly as the + data-source is reordered. */ + .sortable-table-dragging .sortable-source-row > td { + background: rgba(0, 135, 113, 0.10) !important; + transition: background-color 0.18s ease; } - - .ant-table-is-sorting .dragging { - opacity: 1; - box-shadow: 1px -2px 2px #008771; - transition: all 0.2s; + .sortable-table-dragging .sortable-source-row .routing-index, + .sortable-table-dragging .sortable-source-row .outbound-index { + opacity: 0.45; } - - .ant-table-is-sorting .dragging .ant-table-row-index { - opacity: 0.3; + .sortable-table-dragging .sortable-row > td { + transition: background-color 0.18s ease; + } + /* Disable text selection across the whole table while a drag is in + progress — selection during drag is never useful and looks broken. */ + .sortable-table-dragging, + .sortable-table-dragging * { + user-select: none; } </style> -{{end}}
\ No newline at end of file +{{end}} |
