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