tourfp 0.0.0-stage → 0.3.0
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.
- package/README.md +157 -2
- package/package.json +24 -4
- package/tourfp.js +918 -0
package/README.md
CHANGED
|
@@ -1,3 +1,158 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tourfp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Bản thử nghiệm thư viện ghi và phát tour hướng dẫn trên giao diện web, không cần sửa mã nguồn của app.
|
|
4
|
+
Phần tử được nhận diện bằng "dấu vân tay" nhiều tín hiệu (chữ, icon, thuộc tính, component cha, tiêu đề khối đứng trước)
|
|
5
|
+
thay cho CSS selector, nên tour ít gãy khi app đổi class hay cấu trúc DOM.
|
|
6
|
+
|
|
7
|
+
Toàn bộ nằm trong một file: [`tourfp.js`](tourfp.js), không phụ thuộc thư viện nào.
|
|
8
|
+
|
|
9
|
+
## Cài đặt
|
|
10
|
+
|
|
11
|
+
Thư viện là gói npm [`tourfp`](https://www.npmjs.com/package/tourfp), chỉ gồm file `tourfp.js`, không có phụ thuộc và không cần bước build.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install tourfp
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Rồi nạp vào app theo một trong hai cách:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import 'tourfp'; // tạo window.TourFP và hiện thanh ghi tour
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Với Angular, nên khai báo trong `angular.json` để thư viện được nạp trước mã của app. Thứ tự này cần cho việc lọc chữ lấy từ API:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
"scripts": ["node_modules/tourfp/tourfp.js"]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Hoặc không cần cài, nạp bằng một thẻ script qua CDN:
|
|
30
|
+
|
|
31
|
+
```html
|
|
32
|
+
<script src="https://cdn.jsdelivr.net/npm/tourfp@0.3.0/tourfp.js"></script>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Để dùng thử nhanh trên một trang đang mở (trang phải cho phép script từ `cdn.jsdelivr.net`), dán dòng này vào console:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
document.head.appendChild(Object.assign(document.createElement('script'), { src: 'https://cdn.jsdelivr.net/npm/tourfp@0.3.0/tourfp.js' }));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Tuỳ chọn, đặt trước khi nạp thư viện:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
window.TOURFP_SERVER = 'https://tour.example.com'; // máy chủ lưu tour, mặc định http://localhost:8000
|
|
45
|
+
window.TOURFP_PLAYER = true; // chỉ phát, không hiện thanh ghi tour
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Dùng thử
|
|
49
|
+
|
|
50
|
+
Sau khi nạp thư viện (xem Cài đặt), một thanh điều khiển hiện ra ở góc dưới trái
|
|
51
|
+
(kéo đi chỗ khác được):
|
|
52
|
+
|
|
53
|
+
- **● Ghi** — thao tác như bình thường, mỗi cú bấm thành một bước và vẫn chạy thật.
|
|
54
|
+
- Bấm vào phần tử làm đổi màn hoặc đổi nội dung: bước "bấm" (khi phát, người dùng phải tự bấm).
|
|
55
|
+
- Bấm vào phần tử không bấm được, hoặc bấm mà trang không đổi gì: bước "tô sáng" (khi phát có nút Tiếp).
|
|
56
|
+
- Bấm vào mục trong menu bật ra do rê chuột: tự chèn bước "rê chuột" phía trước.
|
|
57
|
+
- Giữ Alt khi bấm: ghi bước "tô sáng" và chặn cú bấm.
|
|
58
|
+
- **▶ Phát** — tô sáng từng đích, chặn bấm ra ngoài đích, tự chờ màn mới hiện ra.
|
|
59
|
+
- Đang ở màn nào thì phát từ bước của màn đó; màn không có trong tour thì đưa về màn đầu.
|
|
60
|
+
- Bước đã được làm sẵn (ví dụ menu đã mở rộng) thì tự bỏ qua.
|
|
61
|
+
- Đang ở màn cuối và bước cuối đã làm xong thì báo, cho chọn phát lại từ đầu.
|
|
62
|
+
- **Xoá** — xoá danh sách bước.
|
|
63
|
+
- **Lưu** — gửi tour lên máy chủ quản trị (xem bên dưới). Nếu trang không cho gọi ra ngoài, JSON của tour được chép vào clipboard.
|
|
64
|
+
|
|
65
|
+
Trong console:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
TourFP.export() // tour hiện tại dạng JSON
|
|
69
|
+
TourFP.load(JSON.parse(json)) // nạp lại một tour
|
|
70
|
+
TourFP.capture(el) // dấu vân tay của một phần tử
|
|
71
|
+
TourFP.find(fp) // tìm phần tử theo dấu vân tay: { status, el, score, ... }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Trang quản trị (`server/`)
|
|
75
|
+
|
|
76
|
+
Một máy chủ FastAPI nhỏ để lưu danh sách tour đã ghi, thêm mới, xoá và chạy thử. Dữ liệu nằm trong SQLite tại `server/data/tours.db`.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -r server/requirements.txt
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uvicorn main:app --app-dir server --host 127.0.0.1 --port 8000
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Mở <http://localhost:8000>:
|
|
87
|
+
|
|
88
|
+
- **Thêm tour** — đặt tên rồi dán JSON lấy bằng `copy(TourFP.export())`, hoặc chọn file `.json`.
|
|
89
|
+
- **Chạy thử** — chép một đoạn mã gồm cả thư viện lẫn tour; mở màn bắt đầu của tour, dán vào console là tour phát ngay.
|
|
90
|
+
Khi chạy thử, thanh ghi tour không hiện (chế độ chỉ phát); gọi `TourFP.showPanel()` nếu cần hiện lại.
|
|
91
|
+
- **Xoá**, **Tải JSON**, và xem danh sách bước của từng tour.
|
|
92
|
+
|
|
93
|
+
Cấu hình bằng biến môi trường: `TOURFP_TARGET` là trang web tour chạy trên đó (mặc định `https://tcinvest.tcbs.com.vn`),
|
|
94
|
+
`TOURFP_DATA` là thư mục chứa file SQLite.
|
|
95
|
+
|
|
96
|
+
Khi thư viện được cài sẵn trong app và app cho phép gọi tới máy chủ này, nút **Lưu** gửi thẳng tour lên máy chủ, và mở
|
|
97
|
+
`<trang>?tourfp=<mã tour>` sẽ tải tour đó về rồi phát. Địa chỉ máy chủ đặt bằng `window.TOURFP_SERVER` trước khi nạp thư viện
|
|
98
|
+
(mặc định `http://localhost:8000`). Trang TCInvest hiện chặn mọi kết nối ra ngoài danh sách cho phép của nó, nên với trang đó
|
|
99
|
+
phải dùng cách dán JSON và dán mã chạy thử.
|
|
100
|
+
|
|
101
|
+
### Trợ lý tìm tour
|
|
102
|
+
|
|
103
|
+
Người dùng nói muốn tới màn hình nào, máy chủ tìm tour phù hợp trong danh sách và trả về hai cách đi:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
curl -X POST http://localhost:8000/api/assist -H 'Content-Type: application/json' -d '{"query": "tôi muốn nộp tiền", "layout": "mobile"}'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Câu trả lời gồm `tour` (tour được chọn), `to_step` (dừng sau bước thứ mấy; mặc định là bước cuối, hoặc bước người dùng nêu như
|
|
110
|
+
"tới bước 3"), `reason` (lý do, để hiện cho người dùng) và `options`:
|
|
111
|
+
|
|
112
|
+
- `guide` — **Hướng dẫn từng bước**: chỉ từng thao tác, người dùng tự bấm.
|
|
113
|
+
- `direct` — **Vào thẳng màn hình**: thư viện tự đưa tới nơi. Nếu bước cuối chuyển sang một trang khác thì vào thẳng trang đó
|
|
114
|
+
bằng đường dẫn; nếu không thì tự bấm các bước trên trang cuối. Gặp bước cần người dùng nhập, hoặc không tìm thấy đích,
|
|
115
|
+
nó chuyển sang hướng dẫn từng bước từ chỗ đó.
|
|
116
|
+
|
|
117
|
+
Mỗi lựa chọn kèm `run_url` (mở trang với `?tourfp=<mã>&mode=<guide|direct>&to=<bước>`, dùng khi thư viện đã cài trong app) và
|
|
118
|
+
`snippet_url` (mã chạy thử để dán vào console). Trên trang quản trị có ô **Trợ lý tìm tour** để thử ngay.
|
|
119
|
+
|
|
120
|
+
Trong app đã cài thư viện và gọi được máy chủ, có thể dùng thẳng:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
TourFP.ask('tôi muốn nộp tiền'); // hỏi trợ lý rồi hiện hai lựa chọn cho người dùng
|
|
124
|
+
TourFP.start({ mode: 'direct', to: 3 }); // chạy tour đang nạp: vào thẳng, dừng sau bước 3
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Chế độ vào thẳng tự bấm thay người dùng. Với tour đi qua thao tác có hậu quả (đặt lệnh, xác nhận giao dịch), nên đặt `to` dừng
|
|
128
|
+
trước các bước đó, hoặc chỉ đưa ra lựa chọn hướng dẫn từng bước.
|
|
129
|
+
|
|
130
|
+
### Nối với mô hình AI
|
|
131
|
+
|
|
132
|
+
Mặc định trợ lý tìm bằng so khớp từ khoá, không gọi AI. Bật AI bằng biến môi trường `TOURFP_AI`:
|
|
133
|
+
|
|
134
|
+
| `TOURFP_AI` | Nối tới | Cần đặt thêm |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `off` (mặc định) | Không gọi AI | |
|
|
137
|
+
| `bedrock` | Amazon Bedrock, qua API Converse | `TOURFP_AI_MODEL`, `AWS_REGION`, thông tin đăng nhập AWS; cài `boto3` |
|
|
138
|
+
| `litellm` | LiteLLM proxy, hoặc máy chủ nào nói giao thức `chat/completions` | `TOURFP_AI_MODEL`, `TOURFP_LITELLM_URL`, `TOURFP_LITELLM_KEY` (nếu proxy yêu cầu) |
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
TOURFP_AI=litellm TOURFP_AI_MODEL=<tên mô hình> TOURFP_LITELLM_URL=http://localhost:4000 uvicorn main:app --app-dir server --port 8000
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Mô hình chỉ nhận tên tour, mô tả và nhãn của từng bước, cùng câu người dùng nhập; không nhận dấu vân tay. Câu trả lời của mô
|
|
145
|
+
hình luôn được kiểm lại (tour phải có thật, số bước phải nằm trong tour). Khi mô hình lỗi hoặc trả lời sai dạng, trợ lý tự
|
|
146
|
+
chuyển sang so khớp từ khoá và ghi lý do vào `ai_error`. Thêm một nhà cung cấp khác bằng cách viết một lớp có hàm
|
|
147
|
+
`complete(system, user)` trong `server/ai.py`.
|
|
148
|
+
|
|
149
|
+
Máy chủ không có đăng nhập; chỉ nên chạy trên `127.0.0.1`.
|
|
150
|
+
|
|
151
|
+
## Giới hạn hiện tại
|
|
152
|
+
|
|
153
|
+
- Trên trang, tour chỉ nằm trong bộ nhớ của tab; tải lại trang là mất. Muốn giữ thì lưu lên trang quản trị.
|
|
154
|
+
- Chữ lấy từ API (tên người, tên kế hoạch…) chỉ được loại khỏi dấu vân tay nếu thư viện được nạp **trước** khi app gọi API,
|
|
155
|
+
tức là phải đặt trong `<head>`. Chữ số luôn bị loại.
|
|
156
|
+
- Lời hướng dẫn từng bước đang là câu mặc định; chưa có AI viết lời hay phân xử khi có nhiều ứng viên ngang điểm.
|
|
157
|
+
- Một số quy tắc đang viết riêng cho Angular Material (lớp phủ `cdk-overlay`, `mat-dialog-container`, `mat-icon`).
|
|
158
|
+
- Ngưỡng tách mobile/desktop (768px) là phỏng đoán. Tour ghi ở bố cục nào chỉ phát được ở bố cục đó.
|
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tourfp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Ghi và phát tour hướng dẫn trên giao diện web bằng dấu vân tay phần tử, không cần sửa mã nguồn của app.",
|
|
5
|
+
"main": "tourfp.js",
|
|
6
|
+
"browser": "tourfp.js",
|
|
7
|
+
"unpkg": "tourfp.js",
|
|
8
|
+
"jsdelivr": "tourfp.js",
|
|
9
|
+
"sideEffects": true,
|
|
10
|
+
"files": [
|
|
11
|
+
"tourfp.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"tour",
|
|
16
|
+
"onboarding",
|
|
17
|
+
"walkthrough",
|
|
18
|
+
"product-tour",
|
|
19
|
+
"guide"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/ngquangdat/tourfp.git"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED"
|
|
26
|
+
}
|
package/tourfp.js
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
/* TourFP 0.3.0 — bản thử: ghi tour theo thao tác và khớp phần tử bằng "dấu vân tay".
|
|
2
|
+
* - Không dùng class/id. Không phụ thuộc thư viện.
|
|
3
|
+
* - Chữ lấy từ API (tên, tên kế hoạch…) và chữ số bị loại khỏi dấu vân tay.
|
|
4
|
+
* Để lọc được, file này phải được nạp TRƯỚC khi app gọi API (đặt trong <head>).
|
|
5
|
+
* - Ghi: bấm bình thường = bước "thao tác" (cú bấm vẫn chạy, app chuyển màn như thật);
|
|
6
|
+
* Alt+bấm = bước "chỉ tô sáng" (cú bấm bị chặn).
|
|
7
|
+
* - Phát: tô sáng đích, chờ người dùng tự bấm rồi mới sang bước sau; tự chờ màn mới hiện ra.
|
|
8
|
+
* start({ mode: 'guide' | 'direct', to }) chạy tới bước thứ `to`: dẫn từng bước, hoặc thư viện tự đưa tới nơi.
|
|
9
|
+
* - Cấu hình (đặt trước khi nạp): window.TOURFP_SERVER = địa chỉ máy chủ lưu tour; window.TOURFP_PLAYER = true để ẩn thanh ghi.
|
|
10
|
+
* API: TourFP.record() | stop() | play() | start(opts) | load(steps) | export() | save() | run(id, opts) | ask(query)
|
|
11
|
+
* | capture(el) | find(fp) | showPanel(on) | destroy()
|
|
12
|
+
*/
|
|
13
|
+
(() => {
|
|
14
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return; // được import ở phía máy chủ (SSR, công cụ build): không làm gì
|
|
15
|
+
if (window.TourFP) window.TourFP.destroy();
|
|
16
|
+
|
|
17
|
+
const W = { label: 40, key: 25, attr: 12, icon: 20, tag: 8, role: 5, comp: 15, anchor: 15, pos: 5 };
|
|
18
|
+
const THRESHOLD = 0.6, MARGIN = 0.08, Z = 2147483000;
|
|
19
|
+
const KEY = ['formcontrolname', 'name', 'data-walkthrough'];
|
|
20
|
+
const SOFT = ['type', 'placeholder', 'aria-label', 'title', 'href', 'panelclass', 'inputmode'];
|
|
21
|
+
const SEMANTIC = 'button,a,input,select,textarea,[role=button],[role=tab],[role=option],[role=menuitem],[role=radio],[role=checkbox],mat-select,mat-radio-button,mat-checkbox,mat-option';
|
|
22
|
+
const LIB = /^(mat|cdk|ng|mdc)-/;
|
|
23
|
+
const ICONS = 'tcbs-icon,mat-icon,svg,img';
|
|
24
|
+
|
|
25
|
+
const norm = s => (s || '').replace(/\s+/g, ' ').trim();
|
|
26
|
+
const stripNum = s => norm(s).replace(/\d[\d.,%]*/g, '#');
|
|
27
|
+
const hasLetters = s => /[A-Za-zÀ-ỹ]/.test(s);
|
|
28
|
+
const hash = s => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36); };
|
|
29
|
+
const isUi = e => !!(e && e.closest && e.closest('[data-tourfp-ui]'));
|
|
30
|
+
const visible = e => e.getClientRects().length > 0;
|
|
31
|
+
const tagOf = e => e.tagName.toLowerCase();
|
|
32
|
+
|
|
33
|
+
// ---------- giá trị từ API: nghe mọi phản hồi JSON, nhớ các chuỗi trong đó ----------
|
|
34
|
+
const api = window.__tourfpApi || (window.__tourfpApi = { set: new Set(), responses: 0, hooked: false, feed: null });
|
|
35
|
+
const API_MAX = 30000, IGNORE_URL = /i18n|\/assets\/|locale|translat/i; // file dịch là chữ cố định, không tính
|
|
36
|
+
const keyOf = s => norm(s).toLowerCase();
|
|
37
|
+
function feed(v, depth) {
|
|
38
|
+
if (v == null || depth > 12 || api.set.size >= API_MAX) return;
|
|
39
|
+
if (typeof v === 'string') { const s = keyOf(v); if (s.length >= 2 && s.length <= 120 && hasLetters(s)) api.set.add(s); }
|
|
40
|
+
else if (Array.isArray(v)) for (const x of v) feed(x, depth + 1);
|
|
41
|
+
else if (typeof v === 'object') for (const k in v) feed(v[k], depth + 1);
|
|
42
|
+
}
|
|
43
|
+
api.feed = (url, body) => {
|
|
44
|
+
try {
|
|
45
|
+
if (IGNORE_URL.test(url || '')) return;
|
|
46
|
+
if (typeof body === 'string') { if (body.length > 3e6) return; const c = body.trimStart()[0]; if (c !== '{' && c !== '[') return; body = JSON.parse(body); }
|
|
47
|
+
feed(body, 0); api.responses++;
|
|
48
|
+
} catch (_) { /* không phải JSON */ }
|
|
49
|
+
};
|
|
50
|
+
if (!api.hooked) {
|
|
51
|
+
api.hooked = true;
|
|
52
|
+
const xo = XMLHttpRequest.prototype.open, xs = XMLHttpRequest.prototype.send;
|
|
53
|
+
XMLHttpRequest.prototype.open = function (m, u) { this.__tfpUrl = String(u); return xo.apply(this, arguments); };
|
|
54
|
+
XMLHttpRequest.prototype.send = function () {
|
|
55
|
+
this.addEventListener('load', () => { try { const t = this.responseType; api.feed(this.__tfpUrl, t === '' || t === 'text' ? this.responseText : t === 'json' ? this.response : null); } catch (_) {} });
|
|
56
|
+
return xs.apply(this, arguments);
|
|
57
|
+
};
|
|
58
|
+
const of = window.fetch;
|
|
59
|
+
if (of) window.fetch = function () {
|
|
60
|
+
const p = of.apply(this, arguments);
|
|
61
|
+
p.then(r => { if (/json/.test(r.headers.get('content-type') || '')) r.clone().text().then(t => api.feed(r.url, t)).catch(() => {}); }).catch(() => {});
|
|
62
|
+
return p;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function isApiValue(piece) {
|
|
66
|
+
const k = keyOf(piece);
|
|
67
|
+
if (api.set.has(k)) return true;
|
|
68
|
+
for (const s of api.set) if (s.length >= 4 && s.length * 2 >= k.length && k.includes(s)) return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------- tín hiệu ----------
|
|
73
|
+
// tách chữ của phần tử thành các mẩu, bỏ chữ số
|
|
74
|
+
function pieces(e) {
|
|
75
|
+
const out = [], num = [];
|
|
76
|
+
let digits = false, nodes = 0;
|
|
77
|
+
const w = document.createTreeWalker(e, NodeFilter.SHOW_TEXT);
|
|
78
|
+
// chỉ cần vài mẩu đầu: với khối lớn (thẻ, bảng) đó là tiêu đề
|
|
79
|
+
for (let n; (n = w.nextNode()) && nodes < 40 && out.length < 6;) {
|
|
80
|
+
const v = n.nodeValue;
|
|
81
|
+
if (!v.trim() || (n.parentElement && n.parentElement.closest('svg,script,style'))) continue;
|
|
82
|
+
nodes++;
|
|
83
|
+
if (/\d/.test(v)) digits = true;
|
|
84
|
+
for (const seg of v.split(/\d[\d.,%\/:-]*/)) { const s = norm(seg); if (s.length >= 2 && hasLetters(s)) { out.push(s.slice(0, 60)); num.push(/\d/.test(v)); } }
|
|
85
|
+
}
|
|
86
|
+
return { all: out, digits, num };
|
|
87
|
+
}
|
|
88
|
+
const lettersLen = s => s.replace(/[\d\s.,%#\/:-]/g, '').length || 1;
|
|
89
|
+
|
|
90
|
+
// nhãn để hiển thị và để so thuộc tính
|
|
91
|
+
function labelOf(e) {
|
|
92
|
+
const a = e.getAttribute('aria-label') || e.getAttribute('title') || e.getAttribute('placeholder') || e.getAttribute('alt');
|
|
93
|
+
if (a) return stripNum(a).slice(0, 60);
|
|
94
|
+
if (e.childElementCount > 12) return '';
|
|
95
|
+
const t = e.textContent;
|
|
96
|
+
if (!t || t.length > 200) return '';
|
|
97
|
+
const s = stripNum(t);
|
|
98
|
+
return s.length <= 60 && hasLetters(s) ? s : '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function iconOf(e) {
|
|
102
|
+
const i = e.matches(ICONS) ? e : e.querySelector(ICONS);
|
|
103
|
+
if (!i || (i !== e && e.childElementCount > 6)) return '';
|
|
104
|
+
const n = i.getAttribute('name') || i.getAttribute('svgicon') || i.getAttribute('fonticon');
|
|
105
|
+
if (n) return n;
|
|
106
|
+
if (tagOf(i) === 'mat-icon' && !i.childElementCount) return norm(i.textContent);
|
|
107
|
+
if (tagOf(i) === 'img') return (i.getAttribute('src') || '').split('/').pop().split('?')[0];
|
|
108
|
+
const p = i.querySelector('use,path');
|
|
109
|
+
if (!p) return '';
|
|
110
|
+
return p.getAttribute('href') || p.getAttribute('xlink:href') || 'd:' + hash(p.getAttribute('d') || '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function compChain(e) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (let p = e.parentElement; p && out.length < 3; p = p.parentElement) {
|
|
116
|
+
const t = tagOf(p);
|
|
117
|
+
if (t.includes('-') && !LIB.test(t)) out.push(t);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// chữ ngắn gần nhất đứng trước phần tử (nhãn ô nhập, tiêu đề khối); bỏ qua nếu là giá trị API
|
|
123
|
+
// các mẩu chữ đứng trước phần tử, theo thứ tự gần đến xa (nhãn ô nhập, tiêu đề khối). Mỗi khối chỉ lấy hai mẩu đầu
|
|
124
|
+
// (tiêu đề như "Số điện thoại"); phần sau thường là dữ liệu của người dùng.
|
|
125
|
+
// stopAt: chỉ lấy chữ nằm bên trong khối này (không leo ra ngoài). out.data[i] = mẩu thứ i lấy từ dòng chữ có chữ số.
|
|
126
|
+
function anchorCands(e, stopAt) {
|
|
127
|
+
const out = []; out.data = [];
|
|
128
|
+
const ff = e.closest('mat-form-field');
|
|
129
|
+
const ml = ff && ff.querySelector('mat-label,label');
|
|
130
|
+
if (ml) { const v = stripNum(ml.textContent).slice(0, 40); if (v && hasLetters(v)) { out.push(v); out.data.push(false); } }
|
|
131
|
+
let cur = e;
|
|
132
|
+
for (let lvl = 0; lvl < 4 && cur && cur !== document.body && cur !== stopAt && out.length < 8; lvl++, cur = cur.parentElement) {
|
|
133
|
+
for (let sib = cur.previousElementSibling, k = 0; sib && k < 3; sib = sib.previousElementSibling, k++) {
|
|
134
|
+
const t = sib.textContent;
|
|
135
|
+
if (!t || t.length > 400) continue;
|
|
136
|
+
const p = pieces(sib);
|
|
137
|
+
p.all.slice(0, 2).forEach((v, i) => { if (v.length <= 40) { out.push(v); out.data.push(p.num[i]); } });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function posOf(e) {
|
|
144
|
+
const p = e.parentElement;
|
|
145
|
+
if (!p) return [0, 1];
|
|
146
|
+
const same = [...p.children].filter(c => c.tagName === e.tagName);
|
|
147
|
+
return [same.indexOf(e), same.length];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- ngữ cảnh màn hình ----------
|
|
151
|
+
// chỉ hộp thoại chặn màn hình mới là "trạng thái màn"; menu bật ra khi rê chuột cũng mang role=dialog nhưng không tính
|
|
152
|
+
const isModal = d => d.matches('[aria-modal=true],mat-dialog-container,dialog[open]') || !!d.closest('.cdk-global-overlay-wrapper');
|
|
153
|
+
const POPUP = '.cdk-overlay-container,[role=menu],[role=dialog],[role=listbox]';
|
|
154
|
+
// menu bật ra (không phải hộp thoại chặn màn hình) đang chứa phần tử, nếu có
|
|
155
|
+
function menuOf(el) {
|
|
156
|
+
const p = el.closest(POPUP);
|
|
157
|
+
return p && !p.matches('.cdk-overlay-container') && !isModal(p) && !p.querySelector('[aria-modal=true],mat-dialog-container') ? p : null;
|
|
158
|
+
}
|
|
159
|
+
function topDialog() {
|
|
160
|
+
const ds = [...document.querySelectorAll('[role=dialog],[aria-modal=true],dialog[open]')].filter(d => visible(d) && !isUi(d) && isModal(d));
|
|
161
|
+
return ds[ds.length - 1] || null;
|
|
162
|
+
}
|
|
163
|
+
function dialogKey(d) {
|
|
164
|
+
for (const c of d.querySelectorAll('*')) { const t = tagOf(c); if (t.includes('-') && !LIB.test(t)) return t; }
|
|
165
|
+
const pane = d.closest('.cdk-overlay-pane');
|
|
166
|
+
return pane ? [...pane.classList].filter(c => !/^cdk-/.test(c)).join('.') : 'dialog';
|
|
167
|
+
}
|
|
168
|
+
function activeStep(root) {
|
|
169
|
+
const a = root.querySelector('[class*=step].active,[class*=step][aria-selected=true],[role=tab][aria-selected=true]');
|
|
170
|
+
return a ? stripNum(a.textContent).slice(0, 30) : '';
|
|
171
|
+
}
|
|
172
|
+
function context() {
|
|
173
|
+
const d = topDialog();
|
|
174
|
+
return {
|
|
175
|
+
path: location.pathname,
|
|
176
|
+
layout: innerWidth < 768 ? 'mobile' : 'desktop', // ngưỡng 768 là giả định, cần chỉnh theo app
|
|
177
|
+
dialog: d ? dialogKey(d) : '',
|
|
178
|
+
step: d ? activeStep(d) : '',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// vùng tìm: nếu có hộp thoại thì chỉ hộp thoại và các lớp phủ mở sau nó
|
|
182
|
+
function roots() {
|
|
183
|
+
const d = topDialog();
|
|
184
|
+
if (!d) return [document.body];
|
|
185
|
+
const pane = d.closest('.cdk-overlay-pane');
|
|
186
|
+
if (!pane || !pane.parentElement) return [d];
|
|
187
|
+
const out = [d];
|
|
188
|
+
let seen = false;
|
|
189
|
+
for (const p of document.querySelectorAll('.cdk-overlay-pane')) {
|
|
190
|
+
if (p === pane) { seen = true; continue; }
|
|
191
|
+
if (seen && visible(p)) out.push(p);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------- ghi dấu vân tay ----------
|
|
197
|
+
function clickableRoot(target) {
|
|
198
|
+
let e = target;
|
|
199
|
+
for (let i = 0; e && i < 5 && e !== document.body; i++, e = e.parentElement) if (e.matches(SEMANTIC)) return e;
|
|
200
|
+
e = target;
|
|
201
|
+
if (getComputedStyle(e).cursor !== 'pointer') return target;
|
|
202
|
+
// leo lên mục bấm được nhỏ nhất: dừng khi cha gom nhiều mục có chữ (ví dụ cả thanh menu)
|
|
203
|
+
const textKids = p => { let n = 0; for (const c of p.children) if (norm(c.textContent)) n++; return n; };
|
|
204
|
+
for (let p = e.parentElement; p && p !== document.body && getComputedStyle(p).cursor === 'pointer' && textKids(p) <= 1; p = p.parentElement) e = p;
|
|
205
|
+
return e;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// "hình dáng" vị trí của phần tử: chuỗi thẻ từ nó lên 5 cấp cha. Các dòng của cùng một bảng có chung chuỗi này.
|
|
209
|
+
const sigOf = e => {
|
|
210
|
+
const out = [];
|
|
211
|
+
for (let p = e, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) out.push(tagOf(p) + (p.getAttribute('role') ? '[' + p.getAttribute('role') + ']' : ''));
|
|
212
|
+
return out.join('<');
|
|
213
|
+
};
|
|
214
|
+
// các phần tử khác trên màn có chữ/icon/thuộc tính y hệt đích (không tính phần tử bọc ngoài hay nằm trong đích)
|
|
215
|
+
function twinsOf(fp, el) {
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const r of roots()) for (const e of r.querySelectorAll(fp.tag)) {
|
|
218
|
+
if (e === el || e.contains(el) || el.contains(e) || isUi(e) || !visible(e) || iconOf(e) !== fp.icon) continue;
|
|
219
|
+
if (fp.label && labelScore(fp, e) < 0.9) continue;
|
|
220
|
+
let same = true;
|
|
221
|
+
for (const k in fp.key) if (e.getAttribute(k) !== fp.key[k]) same = false;
|
|
222
|
+
if (same) out.push(e);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function capture(el) {
|
|
228
|
+
const fp = { v: 2, tag: tagOf(el), role: el.getAttribute('role') || '', label: '', segs: [], mixed: false, dyn: 0, cover: 1, key: {}, attr: {}, icon: iconOf(el), comp: compChain(el), anchor: '', anchorSkip: 0, pos: posOf(el), hint: '', ctx: context() };
|
|
229
|
+
// tiêu đề khối đứng trước: lấy mẩu đầu tiên không phải giá trị API, và nhớ vị trí của nó để lúc khớp đọc đúng mẩu đó
|
|
230
|
+
const ac = anchorCands(el), ai = ac.findIndex(v => !isApiValue(v));
|
|
231
|
+
if (ai >= 0) { fp.anchor = ac[ai]; fp.anchorSkip = ai; }
|
|
232
|
+
const attrLabel = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder') || el.getAttribute('alt');
|
|
233
|
+
if (attrLabel) fp.label = stripNum(attrLabel).slice(0, 60);
|
|
234
|
+
else {
|
|
235
|
+
const p = pieces(el);
|
|
236
|
+
if (p && p.all.length) {
|
|
237
|
+
const stat = p.all.filter(s => !isApiValue(s));
|
|
238
|
+
const big = p.all.length > 2; // khối chứa nhiều chữ: chỉ giữ hai mẩu đầu (tiêu đề), phần sau thường là dữ liệu
|
|
239
|
+
fp.segs = stat.slice(0, big ? 2 : 5);
|
|
240
|
+
fp.label = fp.segs.join(' ').slice(0, 60);
|
|
241
|
+
fp.dyn = p.all.length - stat.length; // số mẩu chữ bị loại vì là giá trị API
|
|
242
|
+
fp.mixed = fp.dyn > 0 || p.digits || big;
|
|
243
|
+
fp.cover = +(lettersLen(fp.segs.join('')) / lettersLen(norm(el.textContent))).toFixed(2);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const k of KEY) { const v = el.getAttribute(k); if (v && !/\d{3,}/.test(v)) fp.key[k] = v; }
|
|
247
|
+
for (const k of SOFT) { const v = el.getAttribute(k); if (v && v.length <= 80) fp.attr[k] = k === 'href' ? v.split('?')[0] : stripNum(v); }
|
|
248
|
+
// số phần tử khác trên màn có chữ/icon/thuộc tính y hệt (ví dụ ba nút bút chì): nếu có thì khối đứng trước là bắt buộc
|
|
249
|
+
fp.twins = 0;
|
|
250
|
+
if (hasStrong(fp)) {
|
|
251
|
+
const group = twinsOf(fp, el);
|
|
252
|
+
fp.twins = group.length;
|
|
253
|
+
if (group.length) {
|
|
254
|
+
// Tiêu đề khối đứng trước chỉ phân biệt được các bản sao khi cả ba điều đúng:
|
|
255
|
+
// - nó nằm bên trong chính "đơn vị lặp" chứa đích (một dòng của bảng, một mục của form), không phải chữ của dòng bên cạnh;
|
|
256
|
+
// - nó là nhãn, không phải dữ liệu (mã, số liệu);
|
|
257
|
+
// - không bản sao nào khác có tiêu đề y hệt.
|
|
258
|
+
// Nếu không, đây là danh sách sinh từ dữ liệu: nhận diện bằng thứ tự, "nút thứ mấy trong danh sách".
|
|
259
|
+
const sig = sigOf(el), peer = group.find(e => sigOf(e) === sig);
|
|
260
|
+
let byAnchor = !!fp.anchor;
|
|
261
|
+
if (byAnchor && peer) {
|
|
262
|
+
let unit = el;
|
|
263
|
+
while (unit.parentElement && !unit.parentElement.contains(peer)) unit = unit.parentElement;
|
|
264
|
+
const inner = anchorCands(el, unit), j = inner.indexOf(fp.anchor);
|
|
265
|
+
byAnchor = j >= 0 && !inner.data[j];
|
|
266
|
+
}
|
|
267
|
+
if (byAnchor) byAnchor = !group.some(e => sim(fp.anchor, anchorCands(e)[fp.anchorSkip] || ''));
|
|
268
|
+
if (!byAnchor) {
|
|
269
|
+
fp.sig = sig;
|
|
270
|
+
fp.nth = group.filter(e => sigOf(e) === sig && (e.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)).length;
|
|
271
|
+
fp.anchor = ''; fp.anchorSkip = 0;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const k0 = Object.keys(fp.key)[0];
|
|
276
|
+
if (k0) fp.hint = (fp.comp[0] ? fp.comp[0] + ' ' : '') + fp.tag + '[' + k0 + '="' + CSS.escape(fp.key[k0]) + '"]';
|
|
277
|
+
return fp;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------- khớp ----------
|
|
281
|
+
function sim(a, b) {
|
|
282
|
+
if (!a || !b) return 0;
|
|
283
|
+
if (a === b) return 1;
|
|
284
|
+
const A = new Set(a.toLowerCase().split(' ')), B = new Set(b.toLowerCase().split(' '));
|
|
285
|
+
let n = 0; for (const x of A) if (B.has(x)) n++;
|
|
286
|
+
const j = n / (A.size + B.size - n);
|
|
287
|
+
return j >= 0.5 ? j : 0;
|
|
288
|
+
}
|
|
289
|
+
// nhãn khớp khi mọi mẩu chữ cố định đều có trong phần tử; phần chữ động được phép khác
|
|
290
|
+
function labelScore(fp, e) {
|
|
291
|
+
if (!fp.segs || !fp.segs.length) return sim(fp.label, labelOf(e));
|
|
292
|
+
const t = e.textContent;
|
|
293
|
+
if (!t) return 0;
|
|
294
|
+
// không giới hạn độ dài: thẻ có biểu đồ chứa rất nhiều chữ số nhưng vẫn là đích hợp lệ
|
|
295
|
+
if (t.length > 4000 && !t.includes(fp.segs[0].split(' ').sort((a, b) => b.length - a.length)[0])) return 0;
|
|
296
|
+
const c = norm(t);
|
|
297
|
+
let hit = 0;
|
|
298
|
+
for (const s of fp.segs) if (c.includes(s)) hit++;
|
|
299
|
+
if (hit < fp.segs.length) return hit * 2 >= fp.segs.length ? 0.5 * hit / fp.segs.length : 0;
|
|
300
|
+
const cover = Math.min(1, lettersLen(fp.segs.join('')) / lettersLen(c));
|
|
301
|
+
return Math.max(0.5, 1 - Math.abs(cover - (fp.cover || 1)));
|
|
302
|
+
}
|
|
303
|
+
const softVal = (e, k) => { const v = e.getAttribute(k); return v == null ? null : k === 'href' ? v.split('?')[0] : stripNum(v); };
|
|
304
|
+
|
|
305
|
+
function score(fp, e) {
|
|
306
|
+
let got = 0, max = 0;
|
|
307
|
+
const add = (w, v) => { max += w; got += w * (v === true ? 1 : v || 0); };
|
|
308
|
+
if (fp.label) add(W.label, labelScore(fp, e));
|
|
309
|
+
for (const k in fp.key) add(W.key, e.getAttribute(k) === fp.key[k]);
|
|
310
|
+
for (const k in fp.attr) add(W.attr, softVal(e, k) === fp.attr[k]);
|
|
311
|
+
if (fp.icon) add(W.icon, iconOf(e) === fp.icon);
|
|
312
|
+
if (fp.tag) add(W.tag, tagOf(e) === fp.tag);
|
|
313
|
+
if (fp.role) add(W.role, e.getAttribute('role') === fp.role);
|
|
314
|
+
if (fp.comp && fp.comp.length) { const c = compChain(e); add(W.comp, c[0] === fp.comp[0] ? 1 : c.includes(fp.comp[0]) || fp.comp.includes(c[0]) ? 0.5 : 0); }
|
|
315
|
+
let anchorHit = 0;
|
|
316
|
+
if (fp.anchor) {
|
|
317
|
+
const ac = anchorCands(e);
|
|
318
|
+
// bản ghi cũ chưa có anchorSkip: chấp nhận nếu trùng một trong các mẩu gần nhất
|
|
319
|
+
anchorHit = fp.anchorSkip == null ? (ac.slice(0, 6).some(v => sim(fp.anchor, v)) ? 1 : 0) : sim(fp.anchor, ac[fp.anchorSkip] || '');
|
|
320
|
+
add(W.anchor, anchorHit);
|
|
321
|
+
}
|
|
322
|
+
if (fp.pos) { const p = posOf(e); add(W.pos, p[0] === fp.pos[0] && p[1] === fp.pos[1]); }
|
|
323
|
+
const s = max ? got / max : 0;
|
|
324
|
+
// phần tử có bản sao giống hệt trên màn: sai khối đứng trước thì không được nhận, kể cả khi mọi thứ khác đều khớp
|
|
325
|
+
return fp.twins && fp.anchor && !anchorHit ? Math.min(s, THRESHOLD - 0.05) : s;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// có ít nhất một tín hiệu mạnh thì mới chấm đầy đủ
|
|
329
|
+
function strongHit(fp, e) {
|
|
330
|
+
if (fp.label && labelScore(fp, e)) return true;
|
|
331
|
+
for (const k in fp.key) if (e.getAttribute(k) === fp.key[k]) return true;
|
|
332
|
+
if (fp.icon && iconOf(e) === fp.icon) return true;
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
const hasStrong = fp => !!(fp.label || fp.icon || Object.keys(fp.key).length);
|
|
336
|
+
|
|
337
|
+
// hệ số "với tới được": bản sao nằm trong ngăn kéo ẩn ngoài màn hình hoặc bị che thì xếp sau bản đang hiện
|
|
338
|
+
function reach(e) {
|
|
339
|
+
const r = e.getBoundingClientRect();
|
|
340
|
+
if (r.right <= 0 || r.left >= innerWidth) return 0.7; // lệch hẳn ra ngoài theo chiều ngang
|
|
341
|
+
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
342
|
+
if (y < 0 || y > innerHeight) return 1; // phải cuộn mới thấy: chưa biết
|
|
343
|
+
const t = document.elementFromPoint(x, y);
|
|
344
|
+
if (!t || isUi(t) || e.contains(t) || t.contains(e)) return 1;
|
|
345
|
+
// bị khuất chỉ vì đang cuộn ra ngoài một khung cuộn (ví dụ thân hộp thoại) thì không phải là bị che
|
|
346
|
+
for (let p = e.parentElement; p && p !== document.body; p = p.parentElement) {
|
|
347
|
+
const s = getComputedStyle(p);
|
|
348
|
+
if (s.overflowY === 'visible' && s.overflowX === 'visible') continue;
|
|
349
|
+
const pr = p.getBoundingClientRect();
|
|
350
|
+
if (x < pr.left || x > pr.right || y < pr.top || y > pr.bottom) return 1;
|
|
351
|
+
}
|
|
352
|
+
return 0.8;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function find(fp, opts = {}) {
|
|
356
|
+
const t0 = performance.now();
|
|
357
|
+
const done = r => Object.assign(r, { ms: +(performance.now() - t0).toFixed(2) });
|
|
358
|
+
if (!opts.ignoreCtx && fp.ctx) {
|
|
359
|
+
const c = context();
|
|
360
|
+
if (c.path !== fp.ctx.path) return done({ status: 'wrong-screen', expected: fp.ctx, actual: c });
|
|
361
|
+
if (c.dialog !== fp.ctx.dialog || c.step !== fp.ctx.step) return done({ status: 'wrong-state', expected: fp.ctx, actual: c });
|
|
362
|
+
}
|
|
363
|
+
const rs = roots();
|
|
364
|
+
if (fp.hint && !opts.noHint) {
|
|
365
|
+
let hits = [];
|
|
366
|
+
try { for (const r of rs) hits.push(...r.querySelectorAll(fp.hint)); } catch (_) { hits = []; }
|
|
367
|
+
hits = hits.filter(visible);
|
|
368
|
+
if (hits.length === 1) { const s = score(fp, hits[0]); if (s >= THRESHOLD) return done({ status: 'ok', el: hits[0], score: s, margin: 1, via: 'hint', scanned: 1 }); }
|
|
369
|
+
}
|
|
370
|
+
const strong = hasStrong(fp);
|
|
371
|
+
const ranked = [];
|
|
372
|
+
let scanned = 0;
|
|
373
|
+
const scan = sel => {
|
|
374
|
+
for (const r of rs) for (const e of r.querySelectorAll(sel)) {
|
|
375
|
+
if (isUi(e) || !visible(e)) continue;
|
|
376
|
+
scanned++;
|
|
377
|
+
if (strong && !strongHit(fp, e)) continue;
|
|
378
|
+
ranked.push({ el: e, score: score(fp, e) });
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
scan(fp.tag || '*');
|
|
382
|
+
ranked.sort((a, b) => b.score - a.score);
|
|
383
|
+
if (fp.tag && (!ranked.length || ranked[0].score < THRESHOLD)) { ranked.length = 0; scan('*'); ranked.sort((a, b) => b.score - a.score); }
|
|
384
|
+
// điểm gốc quyết định có đạt ngưỡng không; điểm đã nhân hệ số chỉ dùng để xếp hạng giữa các bản sao
|
|
385
|
+
for (const c of ranked) c.adj = c.score;
|
|
386
|
+
for (const c of ranked.slice(0, 8)) c.adj = c.score * reach(c.el);
|
|
387
|
+
ranked.sort((a, b) => b.adj - a.adj);
|
|
388
|
+
// ứng viên lồng nhau (nút và chữ bên trong nó) là cùng một đích, không tính là mơ hồ
|
|
389
|
+
let best = ranked[0];
|
|
390
|
+
// giữa một phần tử và khối cha của chính nó thì chọn theo điểm gốc, không để hệ số "bị che" quyết định
|
|
391
|
+
if (best) for (const c of ranked) if (c !== best && c.score > best.score && (c.el.contains(best.el) || best.el.contains(c.el))) best = c;
|
|
392
|
+
const second = best && ranked.find((c, i) => i > 0 && !best.el.contains(c.el) && !c.el.contains(best.el));
|
|
393
|
+
// đích được ghi theo thứ tự trong danh sách: lấy các ứng viên cùng "hình dáng", xếp theo thứ tự trên trang, chọn cái thứ nth
|
|
394
|
+
if (fp.nth != null && fp.sig) {
|
|
395
|
+
const group = ranked.filter(c => c.score >= THRESHOLD && sigOf(c.el) === fp.sig).map(c => c.el)
|
|
396
|
+
.sort((x, y) => (x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
|
|
397
|
+
const pick = group[fp.nth];
|
|
398
|
+
if (!pick) return done({ status: 'not-found', score: best ? best.score : 0, scanned, need: fp.nth + 1, have: group.length });
|
|
399
|
+
return done({ status: 'ok', el: pick, score: score(fp, pick), margin: 1, via: 'thứ ' + (fp.nth + 1) + '/' + group.length, scanned });
|
|
400
|
+
}
|
|
401
|
+
if (!best || best.score < THRESHOLD) return done({ status: 'not-found', score: best ? best.score : 0, scanned });
|
|
402
|
+
const margin = best.adj - (second ? second.adj : 0);
|
|
403
|
+
return done({ status: margin >= MARGIN ? 'ok' : 'ambiguous', el: best.el, score: best.score, margin, via: 'scan', scanned, top: ranked.slice(0, 3) });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// danh sách phần tử bấm được đang hiện (dùng cho kiểm thử)
|
|
407
|
+
function clickables() {
|
|
408
|
+
const out = new Set();
|
|
409
|
+
for (const r of roots()) {
|
|
410
|
+
for (const e of r.querySelectorAll(SEMANTIC)) if (visible(e) && !isUi(e)) out.add(e);
|
|
411
|
+
for (const e of r.querySelectorAll('div,span,li,td,tcbs-icon,mat-icon,img')) {
|
|
412
|
+
if (!visible(e) || isUi(e) || e.closest(SEMANTIC)) continue;
|
|
413
|
+
if (getComputedStyle(e).cursor === 'pointer' && getComputedStyle(e.parentElement).cursor !== 'pointer') out.add(e);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return [...out];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------- giao diện ghi và phát ----------
|
|
420
|
+
const steps = []; // { kind: 'click' | 'hover' | 'input' | 'info', fp, note }
|
|
421
|
+
let recording = false, playing = -1, raf = 0, target = null, token = 0, pending = null, lastCap = 0;
|
|
422
|
+
let hoverEl = null, hoverT = 0, lastDown = 0, popups = [], advancing = false;
|
|
423
|
+
let directRun = 0, fullSteps = null, choice = null; // lượt "vào thẳng" đang chạy; bản đầy đủ của tour khi chỉ chạy tới bước x; hai lựa chọn đang chờ
|
|
424
|
+
// khi đang ghi: menu bật ra do rê chuột (không phải do bấm) thì nhớ phần tử đang được rê lên
|
|
425
|
+
const popMo = new MutationObserver(list => {
|
|
426
|
+
// bỏ qua nếu vừa có cú bấm (trong 1,5 giây, hoặc từ lúc chuột vào phần tử đang rê): khi đó thứ bật ra là do bấm
|
|
427
|
+
if (!recording || performance.now() - lastDown < 1500 || lastDown > hoverT || !hoverEl || !hoverEl.isConnected) return;
|
|
428
|
+
for (const m of list) for (const n of m.addedNodes) {
|
|
429
|
+
if (n.nodeType !== 1 || isUi(n) || !(n.closest('.cdk-overlay-container') || n.matches(POPUP))) continue;
|
|
430
|
+
// hộp thoại chặn màn hình và lớp nền mờ của nó không bao giờ là menu rê chuột
|
|
431
|
+
if (n.matches('.cdk-overlay-backdrop,.cdk-global-overlay-wrapper') || n.closest('.cdk-global-overlay-wrapper') || n.querySelector('[aria-modal=true],mat-dialog-container,.cdk-global-overlay-wrapper')) continue;
|
|
432
|
+
popups.push({ node: n, trig: capture(hoverEl), used: false });
|
|
433
|
+
if (popups.length > 8) popups.shift();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
const mk = (tag, css, text) => { const e = document.createElement(tag); e.setAttribute('data-tourfp-ui', ''); e.style.cssText = css; if (text) e.textContent = text; return e; };
|
|
438
|
+
const FONT = 'font:13px/1.4 system-ui,sans-serif;';
|
|
439
|
+
const NARROW = innerWidth < 480;
|
|
440
|
+
const panel = mk('div', `position:fixed;left:${NARROW ? 8 : 12}px;max-width:calc(100vw - 16px);box-sizing:border-box;bottom:${innerWidth < 768 ? 80 : 12}px;z-index:${Z + 2};background:#16181d;color:#fff;border:1px solid #3a3f4b;border-radius:10px;padding:8px 10px;display:flex;gap:8px;align-items:center;box-shadow:0 6px 24px rgba(0,0,0,.45);${FONT}`);
|
|
441
|
+
const btnCss = `all:unset;cursor:pointer;white-space:nowrap;padding:5px ${NARROW ? 8 : 10}px;border-radius:6px;background:#2a2f3a;color:#fff;${FONT}`;
|
|
442
|
+
const bRec = mk('button', btnCss, '● Ghi'), bPlay = mk('button', btnCss, '▶ Phát'), bClr = mk('button', btnCss, 'Xoá'), bSave = mk('button', btnCss, 'Lưu');
|
|
443
|
+
const info = mk('span', `opacity:.8;min-width:${NARROW ? 0 : 170}px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap`, 'TourFP · 0 bước');
|
|
444
|
+
const grip = mk('span', 'opacity:.5;font-size:16px;line-height:1;padding:0 2px', '⠿');
|
|
445
|
+
panel.append(grip, bRec, bPlay, bClr, bSave, info);
|
|
446
|
+
// kéo thanh điều khiển đi chỗ khác: nắm vào bất kỳ đâu trừ các nút; vị trí được nhớ trong phiên
|
|
447
|
+
const POS = 'tourfp:pos';
|
|
448
|
+
const moveTo = (x, y) => {
|
|
449
|
+
panel.style.left = Math.max(0, Math.min(x, innerWidth - panel.offsetWidth)) + 'px';
|
|
450
|
+
panel.style.top = Math.max(0, Math.min(y, innerHeight - panel.offsetHeight)) + 'px';
|
|
451
|
+
panel.style.bottom = 'auto';
|
|
452
|
+
};
|
|
453
|
+
let drag = null;
|
|
454
|
+
panel.style.cursor = 'move'; panel.style.userSelect = 'none'; panel.style.touchAction = 'none';
|
|
455
|
+
panel.addEventListener('pointerdown', e => {
|
|
456
|
+
if (e.target.closest('button')) return;
|
|
457
|
+
const r = panel.getBoundingClientRect();
|
|
458
|
+
drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
|
|
459
|
+
panel.setPointerCapture(e.pointerId); e.preventDefault();
|
|
460
|
+
});
|
|
461
|
+
panel.addEventListener('pointermove', e => { if (drag) moveTo(e.clientX - drag.dx, e.clientY - drag.dy); });
|
|
462
|
+
const endDrag = () => {
|
|
463
|
+
if (!drag) return;
|
|
464
|
+
drag = null;
|
|
465
|
+
try { sessionStorage.setItem(POS, JSON.stringify([parseFloat(panel.style.left), parseFloat(panel.style.top)])); } catch (_) { /* không nhớ được thì thôi */ }
|
|
466
|
+
};
|
|
467
|
+
panel.addEventListener('pointerup', endDrag);
|
|
468
|
+
panel.addEventListener('pointercancel', endDrag);
|
|
469
|
+
const box = mk('div', `position:fixed;z-index:${Z};pointer-events:none;border:2px solid #ffb020;border-radius:6px;display:none;transition:all .08s`);
|
|
470
|
+
const tip = mk('div', `position:fixed;z-index:${Z + 1};max-width:300px;background:#fff;color:#111;border-radius:8px;padding:10px 12px;display:none;pointer-events:none;box-shadow:0 8px 28px rgba(0,0,0,.4);${FONT}`); // chuột đi xuyên qua chú thích để menu rê chuột bên dưới không tự đóng
|
|
471
|
+
const tipText = mk('div', 'margin-bottom:4px;white-space:pre-line');
|
|
472
|
+
const tipMeta = mk('div', 'margin-bottom:8px;font-size:11px;opacity:.55');
|
|
473
|
+
const bNext = mk('button', btnCss + 'pointer-events:auto;background:#e5484d;', 'Tiếp'), bStop = mk('button', btnCss + 'pointer-events:auto;background:#ddd;color:#111;margin-left:6px', 'Dừng');
|
|
474
|
+
const bClose = mk('button', btnCss + 'pointer-events:auto;background:none;color:#666;margin-left:6px;display:none', 'Đóng');
|
|
475
|
+
tip.append(tipText, tipMeta, bNext, bStop, bClose);
|
|
476
|
+
// chế độ chỉ phát (chạy thử từ trang quản trị, hoặc mở trang với ?tourfp=<mã>): không hiện thanh ghi tour
|
|
477
|
+
const PLAYER = !!window.TOURFP_PLAYER || new URLSearchParams(location.search).has('tourfp');
|
|
478
|
+
if (PLAYER) panel.style.display = 'none';
|
|
479
|
+
document.documentElement.append(box, tip, panel);
|
|
480
|
+
try { const p = JSON.parse(sessionStorage.getItem(POS) || 'null'); if (p) moveTo(p[0], p[1]); } catch (_) { /* vị trí cũ không đọc được */ }
|
|
481
|
+
|
|
482
|
+
const nameOf = fp => (fp.segs && fp.segs[0]) || fp.label || fp.icon || fp.key.name || fp.key.formcontrolname || fp.attr.placeholder || fp.tag;
|
|
483
|
+
// hiển thị chữ lên giao diện; phần đặt trong «…» (tên đích, tên tour) thành chữ in đậm, bỏ dấu ngoặc
|
|
484
|
+
const rich = (node, text) => {
|
|
485
|
+
node.replaceChildren();
|
|
486
|
+
String(text).split(/«([^»]*)»/).forEach((part, i) => {
|
|
487
|
+
if (!part) return;
|
|
488
|
+
if (i % 2) { const b = document.createElement('b'); b.style.fontWeight = '700'; b.textContent = part; node.append(b); } else node.append(part);
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
const setBtns = (next, stop) => {
|
|
492
|
+
bNext.textContent = next || ''; bNext.style.display = next ? '' : 'none';
|
|
493
|
+
bStop.textContent = stop || ''; bStop.style.display = stop ? '' : 'none';
|
|
494
|
+
};
|
|
495
|
+
const setInfo = t => { rich(info, t || (recording ? `Đang ghi · ${steps.length} bước · Alt+bấm = chỉ tô sáng` : `TourFP · ${steps.length} bước`)); };
|
|
496
|
+
function place() {
|
|
497
|
+
if (!target || !target.isConnected) {
|
|
498
|
+
box.style.display = 'none';
|
|
499
|
+
// đích biến mất giữa chừng (ví dụ menu rê chuột tự đóng): quay lại bước rê chuột trước đó rồi chờ lại
|
|
500
|
+
if (target && playing >= 0 && !advancing) {
|
|
501
|
+
target = null;
|
|
502
|
+
let back = playing;
|
|
503
|
+
for (let k = playing - 1; k >= 0 && k >= playing - 3; k--) if (steps[k].kind === 'hover') { back = k; break; }
|
|
504
|
+
show(back);
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const r = target.getBoundingClientRect();
|
|
509
|
+
Object.assign(box.style, { display: 'block', left: r.left - 4 + 'px', top: r.top - 4 + 'px', width: r.width + 8 + 'px', height: r.height + 8 + 'px' });
|
|
510
|
+
// menu bật ra (không phải hộp thoại chặn màn hình) đang chứa đích, nếu có
|
|
511
|
+
const menu = playing >= 0 ? menuOf(target) : null;
|
|
512
|
+
if (playing >= 0 && (menu || steps[playing].kind === 'hover')) {
|
|
513
|
+
// đặt chú thích sát bên cạnh: cạnh đích ở bước rê chuột, cạnh ngoài của menu khi đích nằm trong menu (không che các mục khác)
|
|
514
|
+
const a = menu ? menu.getBoundingClientRect() : r, w = tip.offsetWidth, h = tip.offsetHeight;
|
|
515
|
+
const left = a.right + 12 + w <= innerWidth ? a.right + 12 : Math.max(8, a.left - w - 12);
|
|
516
|
+
tip.style.left = left + 'px'; tip.style.top = Math.max(8, Math.min(r.top, innerHeight - h - 8)) + 'px';
|
|
517
|
+
} else if (playing >= 0) {
|
|
518
|
+
const below = r.bottom + 12 + tip.offsetHeight < innerHeight;
|
|
519
|
+
tip.style.left = Math.max(8, Math.min(r.left, innerWidth - tip.offsetWidth - 8)) + 'px';
|
|
520
|
+
tip.style.top = (below ? r.bottom + 12 : Math.max(8, r.top - tip.offsetHeight - 12)) + 'px';
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const loop = () => { place(); raf = requestAnimationFrame(loop); };
|
|
524
|
+
const flash = c => { const old = box.style.borderColor; box.style.borderColor = c; setTimeout(() => { box.style.borderColor = old; }, 250); };
|
|
525
|
+
|
|
526
|
+
// --- ghi: lấy dấu vân tay lúc nhấn xuống (trước khi app đổi DOM), ghi bước lúc bấm xong
|
|
527
|
+
const onMove = e => {
|
|
528
|
+
if (!recording || isUi(e.target)) return;
|
|
529
|
+
target = clickableRoot(e.target);
|
|
530
|
+
if (!e.target.closest(POPUP) && hoverEl !== target) { hoverEl = target; hoverT = performance.now(); }
|
|
531
|
+
};
|
|
532
|
+
const onDown = e => {
|
|
533
|
+
if (isUi(e.target)) return;
|
|
534
|
+
lastDown = performance.now();
|
|
535
|
+
if (recording) {
|
|
536
|
+
const el = clickableRoot(e.target);
|
|
537
|
+
pending = { el, fp: capture(el) };
|
|
538
|
+
if (e.altKey) e.stopImmediatePropagation();
|
|
539
|
+
} else gate(e, false);
|
|
540
|
+
};
|
|
541
|
+
const onClick = e => {
|
|
542
|
+
if (isUi(e.target)) return;
|
|
543
|
+
if (!recording) return gate(e, true);
|
|
544
|
+
if (e.timeStamp - lastCap < 80) return; // cú bấm lặp do nhãn chuyển tiếp sang ô nhập
|
|
545
|
+
lastCap = e.timeStamp;
|
|
546
|
+
const el = clickableRoot(e.target);
|
|
547
|
+
const fp = pending && (pending.el === el || pending.el.contains(el) || el.contains(pending.el)) ? pending.fp : capture(el);
|
|
548
|
+
pending = null;
|
|
549
|
+
// phần tử không bấm được (tiêu đề, thẻ thông tin) thì là bước "chỉ tô sáng", khi phát có nút Tiếp
|
|
550
|
+
const kind = e.altKey || !isClickable(el) ? 'info' : el.matches('input,textarea') ? 'input' : 'click';
|
|
551
|
+
if (e.altKey) { e.preventDefault(); e.stopImmediatePropagation(); }
|
|
552
|
+
// đích nằm trong menu bật ra do rê chuột: chèn bước "rê chuột" vào trước
|
|
553
|
+
const pop = popups.find(p => !p.used && p.node.isConnected && p.node.contains(el));
|
|
554
|
+
if (pop) { for (const p of popups) if (p.node.contains(el) || pop.node.contains(p.node)) p.used = true; steps.push({ kind: 'hover', fp: pop.trig, note: '' }); }
|
|
555
|
+
const step = { kind, fp, note: '' };
|
|
556
|
+
steps.push(step);
|
|
557
|
+
if (kind === 'click') watchEffect(step);
|
|
558
|
+
flash('#3dd68c'); setInfo();
|
|
559
|
+
};
|
|
560
|
+
const isClickable = el => el.matches(SEMANTIC) || getComputedStyle(el).cursor === 'pointer';
|
|
561
|
+
// cú bấm không làm đổi màn và không làm trang thay đổi gì thì hạ thành bước "chỉ tô sáng"
|
|
562
|
+
const STATE_CLASS = /check|select|activ|expand|open|show|collaps/i;
|
|
563
|
+
function watchEffect(step) {
|
|
564
|
+
const before = context();
|
|
565
|
+
let changed = false;
|
|
566
|
+
const mo = new MutationObserver(list => {
|
|
567
|
+
for (const m of list) {
|
|
568
|
+
if (isUi(m.target)) continue;
|
|
569
|
+
if (m.type === 'childList') {
|
|
570
|
+
for (const n of [...m.addedNodes, ...m.removedNodes]) if (n.nodeType === 1 && !isUi(n) && !/ripple/i.test(n.className && n.className.baseVal === undefined ? n.className : '')) changed = true;
|
|
571
|
+
} else if (m.attributeName === 'class') {
|
|
572
|
+
const a = new Set((m.oldValue || '').split(/\s+/)), b = m.target.classList;
|
|
573
|
+
for (const c of b) if (!a.has(c) && STATE_CLASS.test(c)) changed = true;
|
|
574
|
+
for (const c of a) if (c && !b.contains(c) && STATE_CLASS.test(c)) changed = true;
|
|
575
|
+
} else if (m.oldValue !== m.target.getAttribute(m.attributeName)) changed = true;
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['class', 'aria-checked', 'aria-selected', 'aria-expanded', 'aria-pressed'] });
|
|
579
|
+
setTimeout(() => {
|
|
580
|
+
mo.disconnect();
|
|
581
|
+
step.after = context(); // màn hình sau cú bấm; ở bước cuối, đây là "màn cuối" của tour
|
|
582
|
+
if (!changed && sameCtx(step.after, before)) { step.kind = 'info'; setInfo(); }
|
|
583
|
+
}, 700);
|
|
584
|
+
}
|
|
585
|
+
const onUp = e => { if (recording && e.altKey && !isUi(e.target)) e.stopImmediatePropagation(); else if (!recording && !isUi(e.target)) gate(e, false); };
|
|
586
|
+
|
|
587
|
+
// --- phát: chỉ cho bấm vào đích; bấm đúng đích ở bước "thao tác" thì sang bước sau
|
|
588
|
+
function gate(e, isClick) {
|
|
589
|
+
if (playing < 0 || !target) return;
|
|
590
|
+
const st = steps[playing], inside = target.contains(e.target);
|
|
591
|
+
if (inside && st.kind !== 'info' && st.kind !== 'hover') { if (isClick && st.kind === 'click') { const i = playing; advancing = true; setTimeout(() => { if (playing === i) show(i + 1); }, 300); } return; }
|
|
592
|
+
e.stopImmediatePropagation();
|
|
593
|
+
if (isClick) { e.preventDefault(); flash('#fff'); }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function record() {
|
|
597
|
+
stopPlay(); recording = true; bRec.textContent = '■ Dừng ghi'; bRec.style.background = '#e5484d';
|
|
598
|
+
box.style.borderColor = '#ffb020'; box.style.boxShadow = 'none';
|
|
599
|
+
popups = []; hoverEl = null; popMo.observe(document.body, { childList: true, subtree: true });
|
|
600
|
+
probe();
|
|
601
|
+
setInfo(); cancelAnimationFrame(raf); loop();
|
|
602
|
+
}
|
|
603
|
+
function stop() { recording = false; pending = null; popMo.disconnect(); bRec.textContent = '● Ghi'; bRec.style.background = '#2a2f3a'; target = null; box.style.display = 'none'; cancelAnimationFrame(raf); setInfo(); }
|
|
604
|
+
function stopPlay(msg) {
|
|
605
|
+
token++; directRun++; playing = -1; atEnd = false; choice = null; bClose.style.display = 'none';
|
|
606
|
+
if (fullSteps) { steps.length = 0; steps.push(...fullSteps); fullSteps = null; }
|
|
607
|
+
tip.style.display = 'none'; box.style.display = 'none'; target = null; cancelAnimationFrame(raf); setInfo(msg);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// chờ đến khi màn hình của bước hiện ra và tìm được đích
|
|
611
|
+
// alt: dấu vân tay của bước kế; nếu màn hình đã ở trạng thái của bước kế thì bỏ qua bước này
|
|
612
|
+
// alts: các bước kế tiếp [{fp, idx}]. Nếu đích đang chờ không xuất hiện mà đích của một bước sau đã hiện ổn định
|
|
613
|
+
// (bước này đã được làm sẵn, ví dụ menu đã mở rộng từ trước) thì trả về { skipTo: idx }.
|
|
614
|
+
// menuOnly: chỉ nhận bước sau khi đích của nó nằm trong menu bật ra (dùng khi đang chờ người dùng rê chuột).
|
|
615
|
+
function waitFind(fp, ms = 8000, alts = null, menuOnly = false) {
|
|
616
|
+
const my = ++token;
|
|
617
|
+
return new Promise(res => {
|
|
618
|
+
const t0 = performance.now();
|
|
619
|
+
let deb = 0, timer = 0, last = null, seenIdx = -1, seenT = 0;
|
|
620
|
+
const mo = new MutationObserver(() => { clearTimeout(deb); deb = setTimeout(tryIt, 120); });
|
|
621
|
+
const fin = r => { mo.disconnect(); clearInterval(timer); clearTimeout(deb); res(r); };
|
|
622
|
+
function tryIt() {
|
|
623
|
+
if (my !== token) return fin(null);
|
|
624
|
+
last = find(fp);
|
|
625
|
+
if (last.el) return fin(last);
|
|
626
|
+
if (alts) {
|
|
627
|
+
let hit = -1;
|
|
628
|
+
for (const a of alts) { const r = find(a.fp); if (r.el && (!menuOnly || menuOf(r.el))) { hit = a.idx; break; } }
|
|
629
|
+
if (hit < 0) seenIdx = -1;
|
|
630
|
+
else if (hit !== seenIdx) { seenIdx = hit; seenT = performance.now(); }
|
|
631
|
+
else if (performance.now() - seenT >= (menuOnly ? 400 : 1500)) return fin({ skipTo: hit });
|
|
632
|
+
}
|
|
633
|
+
if (performance.now() - t0 > ms) fin(last);
|
|
634
|
+
}
|
|
635
|
+
mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'aria-selected'] });
|
|
636
|
+
timer = setInterval(tryIt, 600);
|
|
637
|
+
tryIt();
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function show(i) {
|
|
642
|
+
if (i >= steps.length) return stopPlay(`Xong tour · ${steps.length} bước`);
|
|
643
|
+
playing = i; target = null; advancing = false; box.style.display = 'none';
|
|
644
|
+
const st = steps[i], nx = steps[i + 1], name = nameOf(st.fp), head = `Bước ${i + 1}/${steps.length}`;
|
|
645
|
+
// bước rê chuột mà đích của bước sau đã hiện sẵn thì không cần rê nữa
|
|
646
|
+
if (st.kind === 'hover' && nx && find(nx.fp).el) return show(i + 1);
|
|
647
|
+
box.style.borderColor = '#e5484d'; box.style.boxShadow = '0 0 0 9999px rgba(0,0,0,.5)';
|
|
648
|
+
tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px';
|
|
649
|
+
// nút Tiếp chỉ có ở bước nhập và bước chỉ tô sáng; bước cần bấm hoặc rê chuột thì người dùng phải tự làm
|
|
650
|
+
rich(tipText, `${head}: đang chờ màn hình…`); tipMeta.textContent = ''; setBtns(null, 'Dừng');
|
|
651
|
+
setInfo(`${head}: đang tìm`);
|
|
652
|
+
cancelAnimationFrame(raf); loop();
|
|
653
|
+
const after = (from, n) => steps.slice(from, from + n).map((x, k) => ({ fp: x.fp, idx: from + k }));
|
|
654
|
+
const r = await waitFind(st.fp, 8000, after(i + 1, 2));
|
|
655
|
+
if (!r || playing !== i) return;
|
|
656
|
+
if (r.skipTo != null) return show(r.skipTo);
|
|
657
|
+
setInfo(`${head}: ${r.status}`);
|
|
658
|
+
if (!r.el) {
|
|
659
|
+
rich(tipText, `${head}: không tìm thấy «${name}»` + (r.expected ? `\nCần ở màn: ${r.expected.path} ${r.expected.dialog} ${r.expected.step}` : ''));
|
|
660
|
+
tipMeta.textContent = r.status;
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
target = r.el;
|
|
664
|
+
r.el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
665
|
+
rich(tipText, `${head}: ` + (st.note || (st.kind === 'click' ? `Bấm vào «${name}» để tiếp tục` : st.kind === 'hover' ? `Rê chuột vào «${name}»` : st.kind === 'input' ? `Nhập vào ô «${name}», xong bấm Tiếp` : name)));
|
|
666
|
+
tipMeta.textContent = `khớp ${Math.round(r.score * 100)}% · ${r.via} · ${r.ms} ms` + (r.status === 'ambiguous' ? ' · ⚠ mơ hồ' : '');
|
|
667
|
+
// bước cuối: nút thành "Kết thúc". Bước có nút Tiếp thì chỉ còn một nút Kết thúc; bước phải tự bấm thì nút Dừng đổi tên.
|
|
668
|
+
const lastStep = i === steps.length - 1, manual = st.kind === 'input' || st.kind === 'info';
|
|
669
|
+
if (lastStep) setBtns(manual ? 'Kết thúc' : null, manual ? null : 'Kết thúc');
|
|
670
|
+
else setBtns(manual ? 'Tiếp' : null, 'Dừng');
|
|
671
|
+
// bước rê chuột: sang bước sau ngay khi đích của bước sau hiện ra
|
|
672
|
+
if (st.kind === 'hover' && nx) {
|
|
673
|
+
const r2 = await waitFind(nx.fp, 120000, after(i + 2, 2), true);
|
|
674
|
+
if (r2 && playing === i) { if (r2.skipTo != null) show(r2.skipTo); else if (r2.el) show(i + 1); }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// bước đầu tiên thuộc màn đang đứng (cùng đường dẫn, hộp thoại, bước); ưu tiên bước đã tìm thấy đích
|
|
678
|
+
const sameCtx = (a, b) => a.path === b.path && a.dialog === b.dialog && a.step === b.step;
|
|
679
|
+
function startIndex() {
|
|
680
|
+
const c = context();
|
|
681
|
+
let first = -1;
|
|
682
|
+
for (let i = 0; i < steps.length; i++) {
|
|
683
|
+
if (!sameCtx(steps[i].fp.ctx, c)) continue;
|
|
684
|
+
if (first < 0) first = i;
|
|
685
|
+
if (find(steps[i].fp).el) return i;
|
|
686
|
+
}
|
|
687
|
+
return first;
|
|
688
|
+
}
|
|
689
|
+
// màn đang đứng không có trong tour: đưa về màn của bước 1 rồi phát từ đầu
|
|
690
|
+
const RESUME = 'tourfp:resume';
|
|
691
|
+
function goStart() {
|
|
692
|
+
// dấu hiệu app đã thật sự đổi màn: component đứng sau router-outlet khác đi
|
|
693
|
+
const pageSig = () => { const o = document.querySelector('router-outlet'); return o && o.nextElementSibling ? o.nextElementSibling.tagName : ''; };
|
|
694
|
+
const path = steps[0].fp.ctx.path, my = ++token, t0 = performance.now(), before = pageSig(), samePath = location.pathname === path;
|
|
695
|
+
playing = 0; target = null; box.style.display = 'none';
|
|
696
|
+
tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px'; bNext.style.display = 'none';
|
|
697
|
+
rich(tipText, 'Màn này không có trong tour — đang về màn bắt đầu…'); tipMeta.textContent = path; setBtns(null, 'Dừng');
|
|
698
|
+
try { sessionStorage.setItem(RESUME, JSON.stringify(steps)); } catch (_) { /* không lưu được thì thôi */ }
|
|
699
|
+
// đang có hộp thoại mà bước 1 không nằm trong hộp thoại: thử đóng bằng phím Esc trước
|
|
700
|
+
if (topDialog() && !steps[0].fp.ctx.dialog) document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true }));
|
|
701
|
+
// chuyển trang ngay trong app (không tải lại); nếu app không phản ứng thì tải lại và phát tiếp sau khi nạp
|
|
702
|
+
history.pushState({}, '', path);
|
|
703
|
+
dispatchEvent(new PopStateEvent('popstate', { state: null }));
|
|
704
|
+
const timer = setInterval(() => {
|
|
705
|
+
if (my !== token) return clearInterval(timer);
|
|
706
|
+
if ((samePath || !before || pageSig() !== before) && sameCtx(context(), steps[0].fp.ctx) && find(steps[0].fp).el) { clearInterval(timer); try { sessionStorage.removeItem(RESUME); } catch (_) {} show(0); }
|
|
707
|
+
else if (performance.now() - t0 > 3000) { clearInterval(timer); location.assign(path); }
|
|
708
|
+
}, 200);
|
|
709
|
+
}
|
|
710
|
+
// đang đứng ở màn cuối và bước cuối đã làm xong: báo cho người dùng, cho chọn phát lại từ đầu
|
|
711
|
+
let atEnd = false;
|
|
712
|
+
function endNotice() {
|
|
713
|
+
token++; playing = -1; target = null; atEnd = true; box.style.display = 'none'; cancelAnimationFrame(raf);
|
|
714
|
+
tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px';
|
|
715
|
+
rich(tipText, 'Bạn đang ở màn cuối của tour, bước cuối đã được thực hiện.'); tipMeta.textContent = '';
|
|
716
|
+
setBtns('Phát lại từ đầu', 'Dừng');
|
|
717
|
+
setInfo('Đang ở màn cuối của tour');
|
|
718
|
+
}
|
|
719
|
+
async function play() {
|
|
720
|
+
if (recording) stop();
|
|
721
|
+
if (!steps.length) return setInfo('Chưa có bước nào');
|
|
722
|
+
atEnd = false;
|
|
723
|
+
const c = context(), n = steps.length - 1, last = steps[n];
|
|
724
|
+
let first = -1;
|
|
725
|
+
for (let i = 0; i <= n; i++) {
|
|
726
|
+
if (!sameCtx(steps[i].fp.ctx, c)) continue;
|
|
727
|
+
if (first < 0) first = i;
|
|
728
|
+
if (find(steps[i].fp).el) return show(i);
|
|
729
|
+
}
|
|
730
|
+
// không bước nào tìm thấy đích: nếu màn này là màn của (hoặc sau) bước cuối thì coi như đã ở cuối tour
|
|
731
|
+
if ((last.after && sameCtx(last.after, c)) || sameCtx(last.fp.ctx, c)) {
|
|
732
|
+
const r = sameCtx(last.fp.ctx, c) ? await waitFind(last.fp, 1500) : { el: null }; // chờ một nhịp phòng khi màn chưa vẽ xong
|
|
733
|
+
if (!r) return;
|
|
734
|
+
return r.el ? show(n) : endNotice();
|
|
735
|
+
}
|
|
736
|
+
if (first >= 0) show(first); else goStart();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ---------- vào thẳng: thư viện tự làm các bước thay người dùng ----------
|
|
740
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
741
|
+
const navTo = path => { history.pushState({}, '', path); dispatchEvent(new PopStateEvent('popstate', { state: null })); };
|
|
742
|
+
const pressEsc = () => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true }));
|
|
743
|
+
// giả lập rê chuột: sự kiện "enter" không nổi bọt nên bắn riêng cho phần tử và vài cấp cha của nó
|
|
744
|
+
function hoverOn(el) {
|
|
745
|
+
for (const t of ['pointerover', 'mouseover', 'mousemove']) el.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, view: window }));
|
|
746
|
+
for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) for (const t of ['pointerenter', 'mouseenter']) p.dispatchEvent(new MouseEvent(t, { bubbles: false, view: window }));
|
|
747
|
+
}
|
|
748
|
+
async function direct() {
|
|
749
|
+
if (recording) stop();
|
|
750
|
+
if (!steps.length) return setInfo('Chưa có bước nào');
|
|
751
|
+
const my = ++directRun, alive = () => my === directRun;
|
|
752
|
+
const n = steps.length, last = steps[n - 1];
|
|
753
|
+
token++; playing = -1; target = null; atEnd = false; box.style.display = 'none'; cancelAnimationFrame(raf);
|
|
754
|
+
tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px';
|
|
755
|
+
const say = t => { rich(tipText, t); tipMeta.textContent = ''; setBtns(null, 'Dừng'); };
|
|
756
|
+
say('Đang đưa bạn tới màn hình…');
|
|
757
|
+
const finish = () => { say('Đã đưa bạn tới nơi.'); setBtns(null, null); setTimeout(() => { if (alive()) stopPlay('Đã tới nơi'); }, 1600); };
|
|
758
|
+
|
|
759
|
+
// 1) Bước cuối chuyển sang một trang khác và không mở hộp thoại: vào thẳng trang đó bằng đường dẫn.
|
|
760
|
+
const after = last.after;
|
|
761
|
+
if (after && !after.dialog && !after.step && after.path !== last.fp.ctx.path) {
|
|
762
|
+
if (topDialog()) pressEsc();
|
|
763
|
+
if (location.pathname !== after.path) navTo(after.path);
|
|
764
|
+
for (let t = 0; t < 15 && alive() && context().path !== after.path; t++) await sleep(200);
|
|
765
|
+
if (!alive()) return;
|
|
766
|
+
if (context().path !== after.path) return location.assign(after.path); // app không tự chuyển: tải thẳng trang đó
|
|
767
|
+
return finish();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// 2) Còn lại: chỉ làm "chặng cuối", tức các bước liền nhau cùng đường dẫn với bước cuối. Các trang trước đó bỏ qua.
|
|
771
|
+
let first = n - 1;
|
|
772
|
+
while (first > 0 && steps[first - 1].fp.ctx.path === last.fp.ctx.path) first--;
|
|
773
|
+
if (steps[first].fp.ctx.dialog) return play(); // chặng cuối bắt đầu trong một hộp thoại: không tới được bằng đường dẫn, quay về dẫn từng bước
|
|
774
|
+
const c = context();
|
|
775
|
+
let start = -1;
|
|
776
|
+
for (let i = first; i < n && start < 0; i++) if (sameCtx(steps[i].fp.ctx, c) && find(steps[i].fp).el) start = i; // đang đứng sẵn giữa chặng
|
|
777
|
+
if (start < 0) {
|
|
778
|
+
if (topDialog()) pressEsc();
|
|
779
|
+
if (location.pathname !== steps[first].fp.ctx.path) navTo(steps[first].fp.ctx.path);
|
|
780
|
+
start = first;
|
|
781
|
+
}
|
|
782
|
+
for (let i = start; i < n; i++) {
|
|
783
|
+
const st = steps[i];
|
|
784
|
+
if (st.kind === 'info') continue; // bước chỉ để xem, không có gì phải làm
|
|
785
|
+
say(`Đang đưa bạn tới màn hình… (${i + 1}/${n})`);
|
|
786
|
+
const r = await waitFind(st.fp, 6000, steps.slice(i + 1, i + 3).map((x, k) => ({ fp: x.fp, idx: i + 1 + k })));
|
|
787
|
+
if (!r || !alive()) return;
|
|
788
|
+
if (r.skipTo != null) { i = r.skipTo - 1; continue; } // bước này đã được làm sẵn
|
|
789
|
+
if (!r.el || st.kind === 'input') { directRun++; return show(i); } // không tự làm được (không thấy đích, hoặc cần người dùng nhập): dẫn từng bước từ đây
|
|
790
|
+
if (st.kind === 'hover') hoverOn(r.el);
|
|
791
|
+
else { r.el.scrollIntoView({ block: 'center', inline: 'nearest' }); r.el.click(); }
|
|
792
|
+
await sleep(350);
|
|
793
|
+
if (!alive()) return;
|
|
794
|
+
}
|
|
795
|
+
finish();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Bắt đầu một lượt: mode 'guide' (dẫn từng bước, mặc định) hoặc 'direct' (vào thẳng); to = dừng sau bước thứ mấy (mặc định: hết tour).
|
|
799
|
+
function start(opts = {}) {
|
|
800
|
+
if (recording) stop();
|
|
801
|
+
if (!steps.length) return setInfo('Chưa có bước nào');
|
|
802
|
+
if (fullSteps) { steps.length = 0; steps.push(...fullSteps); fullSteps = null; }
|
|
803
|
+
const to = Math.min(Math.max(parseInt(opts.to, 10) || steps.length, 1), steps.length);
|
|
804
|
+
if (to < steps.length) { fullSteps = steps.slice(); steps.length = to; }
|
|
805
|
+
return opts.mode === 'direct' ? direct() : play();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ---------- máy chủ lưu tour (thư mục server/) ----------
|
|
809
|
+
const SERVER = String(window.TOURFP_SERVER || 'http://localhost:8000').replace(/\/$/, '');
|
|
810
|
+
// Chép vào clipboard NGAY trong cú bấm. Nhiều trang (kể cả TCInvest) từ chối quyền ghi clipboard kiểu mới,
|
|
811
|
+
// nên chỉ còn cách cũ, và cách cũ chỉ chạy được khi đang ở trong một cú bấm của người dùng.
|
|
812
|
+
function copyNow(text) {
|
|
813
|
+
const ta = mk('textarea', 'position:fixed;left:0;top:0;opacity:0');
|
|
814
|
+
ta.value = text; document.documentElement.append(ta); ta.select();
|
|
815
|
+
let ok = false;
|
|
816
|
+
try { ok = document.execCommand('copy'); } catch (_) { /* không chép được */ }
|
|
817
|
+
ta.remove();
|
|
818
|
+
return ok;
|
|
819
|
+
}
|
|
820
|
+
// Máy chủ có gọi được không: null = chưa biết. Trang chặn kết nối ra ngoài thì kết quả không đổi, nên chỉ cần dò một lần.
|
|
821
|
+
let serverOk = null;
|
|
822
|
+
const setSaveMode = () => { bSave.textContent = serverOk === false ? 'Chép JSON' : 'Lưu'; };
|
|
823
|
+
async function probe() {
|
|
824
|
+
if (serverOk !== null) return;
|
|
825
|
+
try { serverOk = (await fetch(SERVER + '/api/config')).ok; } catch (_) { serverOk = false; }
|
|
826
|
+
setSaveMode();
|
|
827
|
+
}
|
|
828
|
+
// Gọi được máy chủ: hỏi tên rồi gửi tour lên. Không gọi được: nút thành "Chép JSON" và chép ngay trong cú bấm,
|
|
829
|
+
// để dán vào hộp Thêm tour của trang quản trị.
|
|
830
|
+
async function save() {
|
|
831
|
+
if (recording) stop();
|
|
832
|
+
if (!steps.length) return setInfo('Chưa có bước nào để lưu');
|
|
833
|
+
if (serverOk === false) {
|
|
834
|
+
return setInfo(copyNow(JSON.stringify(steps)) ? 'Đã chép JSON · dán vào Thêm tour ở trang quản trị' : 'Không chép được · dùng copy(TourFP.export()) trong console');
|
|
835
|
+
}
|
|
836
|
+
const name = (prompt('Tên tour:') || '').trim();
|
|
837
|
+
if (!name) return;
|
|
838
|
+
try {
|
|
839
|
+
const r = await fetch(SERVER + '/api/tours', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, steps }) });
|
|
840
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
841
|
+
serverOk = true;
|
|
842
|
+
setInfo(`Đã lưu «${name}»`);
|
|
843
|
+
} catch (_) {
|
|
844
|
+
serverOk = false; setSaveMode();
|
|
845
|
+
setInfo('Không gọi được máy chủ · bấm Chép JSON');
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// tải một tour từ máy chủ theo mã rồi phát
|
|
849
|
+
async function run(id, opts = {}) {
|
|
850
|
+
const r = await fetch(`${SERVER}/api/tours/${encodeURIComponent(id)}`);
|
|
851
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
852
|
+
const t = await r.json();
|
|
853
|
+
fullSteps = null; steps.length = 0; steps.push(...t.steps); setInfo();
|
|
854
|
+
start(opts);
|
|
855
|
+
}
|
|
856
|
+
// Hỏi trợ lý trên máy chủ "muốn tới màn hình nào", rồi cho người dùng chọn: dẫn từng bước hay vào thẳng.
|
|
857
|
+
async function ask(query) {
|
|
858
|
+
let a;
|
|
859
|
+
try {
|
|
860
|
+
const r = await fetch(SERVER + '/api/assist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, layout: context().layout }) });
|
|
861
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
862
|
+
a = await r.json();
|
|
863
|
+
} catch (e) {
|
|
864
|
+
a = { matched: false, reason: 'Không gọi được trợ lý.' };
|
|
865
|
+
}
|
|
866
|
+
stopPlay();
|
|
867
|
+
tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px'; tipMeta.textContent = '';
|
|
868
|
+
bClose.style.display = '';
|
|
869
|
+
if (!a.matched) { rich(tipText, a.reason || 'Chưa tìm thấy tour phù hợp.'); setBtns(null, null); choice = {}; return a; }
|
|
870
|
+
rich(tipText, `«${a.tour.name}»` + (a.to_step < a.tour.step_count ? ` · tới bước ${a.to_step}/${a.tour.step_count}` : '') + '\n' + a.reason);
|
|
871
|
+
setBtns(a.options[0].label, a.options[1].label);
|
|
872
|
+
const go = mode => run(a.tour.id, { mode, to: a.to_step }).catch(() => { rich(tipText, 'Không tải được tour.'); });
|
|
873
|
+
choice = { guide: () => go('guide'), direct: () => go('direct') };
|
|
874
|
+
return a;
|
|
875
|
+
}
|
|
876
|
+
bSave.onclick = save;
|
|
877
|
+
if (!PLAYER) probe(); // biết trước nên gửi hay chép, để cú bấm đầu tiên vào nút đã làm đúng việc
|
|
878
|
+
bRec.onclick = () => (recording ? stop() : record());
|
|
879
|
+
bPlay.onclick = play;
|
|
880
|
+
bClr.onclick = () => { fullSteps = null; steps.length = 0; stopPlay(); };
|
|
881
|
+
const pick = k => { const f = choice[k]; choice = null; bClose.style.display = 'none'; if (f) f(); };
|
|
882
|
+
bNext.onclick = () => { if (choice) pick('guide'); else if (atEnd) { atEnd = false; goStart(); } else show(playing + 1); };
|
|
883
|
+
bStop.onclick = () => { if (choice) pick('direct'); else stopPlay(); };
|
|
884
|
+
bClose.onclick = () => stopPlay();
|
|
885
|
+
const L = [['mousemove', onMove], ['pointerdown', onDown], ['mousedown', onUp], ['pointerup', onUp], ['mouseup', onUp], ['click', onClick]];
|
|
886
|
+
for (const [n, h] of L) document.addEventListener(n, h, true);
|
|
887
|
+
|
|
888
|
+
function destroy() {
|
|
889
|
+
token++; cancelAnimationFrame(raf); popMo.disconnect();
|
|
890
|
+
for (const [n, h] of L) document.removeEventListener(n, h, true);
|
|
891
|
+
box.remove(); tip.remove(); panel.remove();
|
|
892
|
+
delete window.TourFP;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
window.TourFP = {
|
|
896
|
+
capture, captureAt: el => capture(clickableRoot(el)), isClickable, save, run, start, direct, ask,
|
|
897
|
+
showPanel: (on = true) => { panel.style.display = on ? 'flex' : 'none'; }, find, score, context, clickables, record, stop, play, destroy, steps,
|
|
898
|
+
apiStats: () => ({ responses: api.responses, values: api.set.size }),
|
|
899
|
+
isApiValue,
|
|
900
|
+
state: () => ({ recording, playing, steps: steps.length, target: target ? nameOf(capture(target)) : null, tip: tip.style.display === 'block' ? tipText.textContent : '' }),
|
|
901
|
+
export: () => JSON.stringify(steps, null, 2),
|
|
902
|
+
load: a => { fullSteps = null; steps.length = 0; steps.push(...a); setInfo(); },
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
// vừa bị tải lại trang giữa lúc đưa về màn bắt đầu: nạp lại tour và phát tiếp (không điều hướng lần nữa)
|
|
906
|
+
try {
|
|
907
|
+
const saved = sessionStorage.getItem(RESUME);
|
|
908
|
+
if (saved) {
|
|
909
|
+
sessionStorage.removeItem(RESUME);
|
|
910
|
+
steps.push(...JSON.parse(saved)); setInfo();
|
|
911
|
+
setTimeout(() => { const i = startIndex(); show(i >= 0 ? i : 0); }, 800);
|
|
912
|
+
}
|
|
913
|
+
} catch (_) { /* không đọc được thì bỏ qua */ }
|
|
914
|
+
|
|
915
|
+
// mở trang với ?tourfp=<mã>[&mode=guide|direct][&to=<bước>]: tải tour đó từ máy chủ và chạy luôn
|
|
916
|
+
const qs = new URLSearchParams(location.search), wanted = qs.get('tourfp');
|
|
917
|
+
if (wanted && !steps.length) run(wanted, { mode: qs.get('mode'), to: qs.get('to') }).catch(() => setInfo('Không tải được tour ' + wanted));
|
|
918
|
+
})();
|