@swimmingliu/autovpn 1.8.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ import { icon } from './icons.js';
2
+ import { readPreference, writePreference } from './appearance.js';
3
+
4
+ const TABLES = {
5
+ nodes: [
6
+ ['index', '#', 56, 48], ['name', '节点名称', 260, 160],
7
+ ['address', 'IP地址', 200, 140], ['protocol', '协议', 100, 80], ['path', 'path', 300, 160]
8
+ ],
9
+ accounts: [
10
+ ['account', '账号', 240, 160], ['brand', '加速器', 110, 90],
11
+ ['status', '状态', 160, 120], ['expiry', '到期', 150, 120],
12
+ ['usage', '使用', 180, 140], ['actions', '操作', 96, 80]
13
+ ]
14
+ };
15
+ const initialized = new WeakSet();
16
+ const MAX_WIDTH = 900;
17
+
18
+ function normalizeLayout(name) {
19
+ const columns = TABLES[name];
20
+ const saved = readPreference(`autovpn.table.${name}`, {});
21
+ const validKeys = columns.map(([key]) => key);
22
+ const order = [...new Set(Array.isArray(saved?.order) ? saved.order : [])]
23
+ .filter((key) => validKeys.includes(key));
24
+ const widths = Object.fromEntries(columns.map(([key, , initial, minimum]) => {
25
+ const width = saved?.widths?.[key];
26
+ return [key, typeof width === 'number' && Number.isFinite(width)
27
+ ? Math.max(minimum, Math.min(MAX_WIDTH, width)) : initial];
28
+ }));
29
+ return { order: [...order, ...validKeys.filter((key) => !order.includes(key))], widths };
30
+ }
31
+
32
+ export function enhanceTables(root) {
33
+ for (const table of root.querySelectorAll('[data-column-table]')) {
34
+ const name = table.dataset.columnTable;
35
+ if (!TABLES[name] || initialized.has(table)) continue;
36
+ initialized.add(table);
37
+ enhanceTable(table, name);
38
+ }
39
+ }
40
+
41
+ function enhanceTable(table, name) {
42
+ const columns = TABLES[name];
43
+ const wrap = table.closest('.table-wrap');
44
+ let layout = normalizeLayout(name);
45
+ const headers = [...table.tHead.rows[0].cells];
46
+ const colgroup = table.querySelector('colgroup') ?? table.insertBefore(document.createElement('colgroup'), table.firstChild);
47
+ colgroup.replaceChildren(...columns.map(([key]) => {
48
+ const col = document.createElement('col');
49
+ col.dataset.columnKey = key;
50
+ return col;
51
+ }));
52
+ for (const row of table.rows) {
53
+ if (row.cells.length !== columns.length) continue;
54
+ [...row.cells].forEach((cell, index) => { cell.dataset.columnKey = columns[index][0]; });
55
+ }
56
+ wrap.tabIndex = 0;
57
+ wrap.setAttribute('role', 'region');
58
+ wrap.setAttribute('aria-label', name === 'nodes' ? '节点列表,可横向滚动' : '账号列表,可横向滚动');
59
+
60
+ const tools = document.createElement('div');
61
+ tools.className = 'table-tools';
62
+ tools.innerHTML = `<span class="sr-only" role="status" aria-live="polite"></span>
63
+ <details class="column-options"><summary title="调整列表列" aria-label="调整列表列">${icon('columns')}</summary>
64
+ <div class="column-options-body"><div class="column-options-heading"><span>顺序与列宽</span><button class="btn btn-secondary small" data-columns-reset type="button">重置</button></div>
65
+ ${columns.map(([key, label, , minimum]) => `<div class="column-option" data-column-option="${key}"><span>${label}</span><button class="icon-btn" data-column-move="left" aria-label="左移${label}列" title="左移${label}列" type="button">${icon('left')}</button><button class="icon-btn" data-column-move="right" aria-label="右移${label}列" title="右移${label}列" type="button">${icon('right')}</button><label class="column-width"><span class="sr-only">${label}列宽,像素</span><input type="number" inputmode="numeric" data-column-width="${key}" min="${minimum}" max="${MAX_WIDTH}" step="16" /></label></div>`).join('')}
66
+ </div></details>`;
67
+ wrap.before(tools);
68
+ const options = tools.querySelector('details');
69
+ tools.addEventListener('keydown', (event) => {
70
+ if (event.key === 'Escape') {
71
+ options.open = false;
72
+ options.querySelector('summary').focus();
73
+ }
74
+ });
75
+ const save = () => writePreference(`autovpn.table.${name}`, layout);
76
+ const announce = (text) => { tools.querySelector('[role="status"]').textContent = text; };
77
+ const apply = () => {
78
+ for (const row of [colgroup, ...table.rows]) {
79
+ const cells = new Map([...row.children].map((cell) => [cell.dataset.columnKey, cell]));
80
+ if (cells.size !== columns.length || cells.has(undefined)) continue;
81
+ // Reparenting the captured resize handle on every pointermove cancels
82
+ // pointer capture after the first movement. Only reorder changed rows.
83
+ const currentOrder = [...row.children].map((cell) => cell.dataset.columnKey);
84
+ if (currentOrder.some((key, index) => key !== layout.order[index])) {
85
+ row.append(...layout.order.map((key) => cells.get(key)));
86
+ }
87
+ }
88
+ for (const [key, label] of columns) {
89
+ colgroup.querySelector(`[data-column-key="${key}"]`).style.width = `${layout.widths[key]}px`;
90
+ const handle = table.querySelector(`[data-column-key="${key}"] .column-resize`);
91
+ handle?.setAttribute('aria-valuenow', String(Math.round(layout.widths[key])));
92
+ handle?.setAttribute('aria-valuetext', `${Math.round(layout.widths[key])} 像素`);
93
+ const option = tools.querySelector(`[data-column-option="${key}"]`);
94
+ option.querySelector('input').value = String(Math.round(layout.widths[key]));
95
+ option.querySelector('[data-column-move="left"]').disabled = layout.order[0] === key;
96
+ option.querySelector('[data-column-move="right"]').disabled = layout.order.at(-1) === key;
97
+ option.style.order = layout.order.indexOf(key);
98
+ }
99
+ table.style.width = `${Object.values(layout.widths).reduce((sum, width) => sum + width, 0)}px`;
100
+ };
101
+ const move = (key, targetIndex) => {
102
+ const index = layout.order.indexOf(key);
103
+ const target = Math.max(0, Math.min(layout.order.length - 1, targetIndex));
104
+ if (index === target) return;
105
+ layout.order.splice(index, 1);
106
+ layout.order.splice(target, 0, key);
107
+ const focused = document.activeElement;
108
+ apply();
109
+ // Moving an existing header detaches its focused grip. Restore it so
110
+ // successive keyboard moves stay on the same column.
111
+ if (focused instanceof HTMLElement && table.contains(focused)) focused.focus();
112
+ save();
113
+ announce(`已将${columns.find(([id]) => id === key)[1]}移到第 ${target + 1} 列`);
114
+ };
115
+ const resize = (key, value) => {
116
+ const minimum = columns.find(([id]) => id === key)[3];
117
+ if (!Number.isFinite(value)) return;
118
+ layout.widths[key] = Math.max(minimum, Math.min(MAX_WIDTH, value));
119
+ apply();
120
+ };
121
+ let draggedKey = null;
122
+ headers.forEach((header, index) => {
123
+ const [key, label, , minimum] = columns[index];
124
+ header.innerHTML = `<div class="column-heading"><button class="column-grip" draggable="true" type="button" aria-label="移动${label}列" title="拖动调整顺序;Alt + 左右键移动">${icon('grip')}</button><span>${label}</span></div><span class="column-resize" role="separator" tabindex="0" aria-orientation="vertical" aria-label="调整${label}列宽" aria-valuemin="${minimum}" aria-valuemax="${MAX_WIDTH}" title="拖动调整列宽;左右键调整"></span>`;
125
+ header.scope = 'col';
126
+ header.addEventListener('dragstart', (event) => {
127
+ if (!event.target.closest('.column-grip')) return;
128
+ draggedKey = key;
129
+ event.dataTransfer.setData('text/plain', key);
130
+ event.dataTransfer.effectAllowed = 'move';
131
+ header.classList.add('is-dragging');
132
+ });
133
+ header.addEventListener('dragover', (event) => {
134
+ if (!draggedKey) return;
135
+ event.preventDefault();
136
+ event.dataTransfer.dropEffect = 'move';
137
+ });
138
+ header.addEventListener('drop', (event) => {
139
+ if (!draggedKey) return;
140
+ event.preventDefault();
141
+ move(draggedKey, layout.order.indexOf(key));
142
+ table.querySelector(`[data-column-key="${draggedKey}"] .column-grip`).focus();
143
+ draggedKey = null;
144
+ });
145
+ header.addEventListener('dragend', () => {
146
+ draggedKey = null;
147
+ header.classList.remove('is-dragging');
148
+ });
149
+ header.querySelector('.column-grip').addEventListener('keydown', (event) => {
150
+ if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
151
+ event.preventDefault();
152
+ move(key, layout.order.indexOf(key) + (event.key === 'ArrowLeft' ? -1 : 1));
153
+ });
154
+ const handle = header.querySelector('.column-resize');
155
+ let start = null;
156
+ handle.addEventListener('pointerdown', (event) => {
157
+ if (event.button !== 0) return;
158
+ event.preventDefault();
159
+ // Freeze rendered widths before resizing so a table stretched to fit its
160
+ // container does not redistribute every other column during the drag.
161
+ for (const cell of headers) layout.widths[cell.dataset.columnKey] = cell.getBoundingClientRect().width;
162
+ start = { x: event.clientX, width: layout.widths[key] };
163
+ handle.setPointerCapture(event.pointerId);
164
+ table.classList.add('is-resizing');
165
+ });
166
+ handle.addEventListener('pointermove', (event) => {
167
+ if (start) resize(key, start.width + event.clientX - start.x);
168
+ });
169
+ const endResize = () => {
170
+ if (!start) return;
171
+ start = null;
172
+ table.classList.remove('is-resizing');
173
+ save();
174
+ };
175
+ handle.addEventListener('pointerup', endResize);
176
+ handle.addEventListener('lostpointercapture', endResize);
177
+ handle.addEventListener('keydown', (event) => {
178
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
179
+ event.preventDefault();
180
+ const value = event.key === 'Home' ? minimum : event.key === 'End' ? MAX_WIDTH
181
+ : layout.widths[key] + (event.key === 'ArrowLeft' ? -1 : 1) * (event.shiftKey ? 64 : 16);
182
+ resize(key, value);
183
+ save();
184
+ });
185
+ });
186
+ tools.addEventListener('click', (event) => {
187
+ if (event.target.closest('[data-columns-reset]')) {
188
+ layout = { order: columns.map(([key]) => key), widths: Object.fromEntries(columns.map(([key, , width]) => [key, width])) };
189
+ apply();
190
+ save();
191
+ announce('已重置列顺序与宽度');
192
+ return;
193
+ }
194
+ const button = event.target.closest('[data-column-move]');
195
+ if (!button) return;
196
+ const key = button.closest('[data-column-option]').dataset.columnOption;
197
+ move(key, layout.order.indexOf(key) + (button.dataset.columnMove === 'left' ? -1 : 1));
198
+ if (button.disabled) button.parentElement.querySelector(`[data-column-move="${button.dataset.columnMove === 'left' ? 'right' : 'left'}"]`).focus();
199
+ });
200
+ tools.addEventListener('change', (event) => {
201
+ const key = event.target.dataset.columnWidth;
202
+ if (!key) return;
203
+ resize(key, event.target.valueAsNumber);
204
+ apply();
205
+ save();
206
+ });
207
+ apply();
208
+ }