forked from kong0107/zhLawEasyRead
-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathLER.front.js
278 lines (253 loc) · 10.6 KB
/
LER.front.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
/**
* @module LER
* @desc 於本專案被安裝為瀏覽器外掛時,用於 `LER.back.js` 的同名函數。
*/
var LER = LER || (() => {
const obj = {};
const browser = globalThis?.browser || globalThis?.chrome;
['fetchText', 'loadRules', 'parseString', 'preparePopup']
.forEach(method => {
obj[method] = function(options = {}) {
return browser?.runtime?.sendMessage({method, ...options});
};
});
return obj;
})();
Object.assign(LER, {
/** @type {Element} */
popupTemplate: kongUtil.createElementFromJsonML(
["div", {
"class": "LER-popup-container",
"style": "display: none;"
},
["div", {"class": "LER-popup-before"}],
["div", {"class": "LER-popup"},
["label", {
"class": "LER-popup-pin",
"title": "固定"
},
["input", {"type": "checkbox"}], // checkbox 不能用 pseudo-element
["i"],
],
["header"],
["dl", {"class": "LER-popup-body"}]
],
["div", {"class": "LER-popup-after"}]
]
),
/** @type {Object} */
pageDefaultLaw: null,
/** @type {integer} */
counter: 0,
/**
* 轉換指定元素內的文字節點。
* @param {Element} element
* @param {Object} [options]
* @returns {Promise.<Element>}
*/
async parseElement(
element, {
defaultLaw,
articleNumberFormat = this.articleNumberFormat,
enablePopup = true
}
) {
// console.debug('LER.parseElement()');
console.time("LawEasyRead" + (++this.counter));
await this.loadRules();
// 取得所有要處理的文字節點
const textNodes = [];
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
node => {
if(node.nodeType === Node.TEXT_NODE) {
return /[\u4E00-\u9FFF]{2}/.test(node.textContent) // 有連續中日韓字元
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
if('A,BUTTON,CODE,SCRIPT,SELECT,STYLE,TEMPLATE,TEXTAREA'.split(',').includes(node.tagName)) return NodeFilter.FILTER_REJECT;
return node.classList.contains('LER-skip') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_SKIP;
}
);
let node;
while(node = walker.nextNode()) textNodes.push(node);
return new Promise(resolve => {
const LER = this;
const currentCounter = this.counter;
async function parseNextTextNode() {
const node = textNodes.shift();
// console.debug('parseNextTextNode()');
if(!node) {
console.timeEnd("LawEasyRead" + currentCounter);
return resolve(element);
}
let objects = await LER.parseString({
string: node.textContent,
allowLink: !node.parentNode?.closest?.("a"),
articleNumberFormat,
defaultLaw
});
requestIdleCallback(parseNextTextNode);
// console.debug(objects);
if(objects.length === 1 && objects[0] === node.textContent) return; // 沒變的話就不替換
// 扁平化。但由於 JsonML 自身結構已是陣列,故不方便使用 `Array.flat()` 。
objects = objects.reduce((acc, cur) => {
if(typeof cur === 'string'
|| /[a-z]+/.test(cur[0]) && !(cur[1] instanceof Array)
) acc.push(cur);
else acc.push(...cur);
return acc;
}, []);
objects = objects.map(kongUtil.createElementFromJsonML);
const next = node.nextSibling;
node.replaceWith(...objects);
if(enablePopup) objects.forEach(o => LER.bindPopup(o, articleNumberFormat));
if(!next) {
const parent = objects[0].parentNode;
const event = new CustomEvent("lerParseEnd");
parent.dispatchEvent(event);
}
}
requestIdleCallback(parseNextTextNode);
});
},
/**
*
* @param {Object} options
* @returns {Promise.<HTMLBodyElement>}
*/
parseDocument(options) {
// console.debug('LER.parseDocument()');
this.articleNumberFormat = options.articleNumberFormat || 'unchanged';
return this.parseElement(
document.body,
Object.assign({defaultLaw: this.pageDefaultLaw}, options)
);
},
/**
* 綁定滑鼠移過時的彈出式視窗。
* @param {Element} elem
* @returns {void}
*
* 做四件事:
* 1. 滑鼠首次移入目標時,同步建立彈出式視窗,異步載入資料。載入資料後若視窗仍處於顯示狀態,則再次定位視窗。
* 2. 滑鼠移入目標時,則設定稍後顯示並定位視窗。
* 3. 滑鼠移出目標時,若視窗尚未顯示,則取消前項設定。
* 4. 滑鼠移動時,若不在顯示中的視窗或其目標內,且視窗未被釘選,則隱藏視窗。(另處監聽 document 的 mousemove 事件)
*
* 備註:由於在 shadow tree 裡的 Event.target 在事件結束後會被清掉,所以先複製需要的資料出來。
* 參考:
* * https://stackoverflow.com/questions/57963312/
* * https://stackoverflow.com/questions/62181537/
*/
bindPopup(elem, articleNumberFormat) {
// console.debug('LER.bindPopup()');
if(!(elem instanceof Element)) return;
const {jyi, pcode, word} = elem.dataset;
if(!jyi && !pcode && !word) return;
let popup;
elem.addEventListener('mouseenter', event => {
// console.debug('mouseenter', event);
const fakeEvent = {target: event.target, clientX: event.clientX, pageX: event.pageX};
// 為同步建立元件,就不從後端取得 JsonML ,而是複製已載入的 DOM 。
popup = this.popupTemplate.cloneNode(true);
popup.target = elem;
popup.addEventListener('mouseleave', e => {
if(kongUtil.isEventInElement(e, elem)) return;
if(kongUtil.isEventInElement(e, popup)) return;
if(popup.querySelector('[type=checkbox]').checked) return;
popup.style.display = 'none';
});
const body = popup.querySelector('.LER-popup-body');
body.textContent = '讀取中…';
this.getShadowRoot().append(popup);
// 異步載入資料。
this.preparePopup(elem.dataset)
.then(({headers, bodyParts, defaultLaw}) => {
popup.querySelector('header').append(...headers.map(kongUtil.createElementFromJsonML));
body.textContent = '';
body.append(...bodyParts.map(kongUtil.createElementFromJsonML));
this.parseElement(body, {defaultLaw, articleNumberFormat});
if(!popup.style.display) this.setPopupPosition(popup, fakeEvent); ///< 載入內容後高度可能有變化,要重新定位,但是只能依賴舊的滑鼠事件位置。
});
}, {once: true});
let timeoutID;
elem.addEventListener('mouseenter', event => {
// console.debug('mouseenter', event);
const fakeEvent = {target: event.target, clientX: event.clientX, pageX: event.pageX};
if(!popup) throw new ReferenceError("popup does not exist.");
if(!popup.style.display) return;
timeoutID = setTimeout(this.setPopupPosition, 375, popup, fakeEvent);
});
elem.addEventListener('mouseleave', event => {
// console.debug('mouseleave', elem);
clearTimeout(timeoutID);
if(kongUtil.isEventInElement(event, popup)) return;
if(popup.querySelector('[type=checkbox]').checked) return;
popup.style.display = 'none';
});
},
/**
* 設定彈出式視窗位置。
* @param {Element} popup
* @param {MouseEvent} event
* @returns {undefined}
*/
setPopupPosition(popup, event) {
// console.debug('LER.setPopupPosition()', event);
let arrow; ///< 稍後判斷箭頭是上面還是下面
const rect = event.target.getBoundingClientRect(); ///< 相對於當前可視範圍,而非相對於文件左上角
/// 位置跟尺寸的資訊必須在元素顯示後才能取得,故先顯示其中一個箭頭再看高度。
popup.firstChild.style.display = "none";
popup.lastChild.style.display = "";
popup.style.display = "";
/// Y軸:預設為目標元素的下緣,但若會超出可視範圍(即使未超過文件範圍),則改在目標元素的上緣。
let top = Math.floor(rect.bottom + window.scrollY);
if(top + popup.offsetHeight > window.scrollY + window.innerHeight) {
top = Math.ceil(rect.top + window.scrollY - popup.offsetHeight);
arrow = popup.lastChild;
arrow.style.display = "";
}
else {
popup.lastChild.style.display = "none";
arrow = popup.firstChild;
arrow.style.display = "";
}
popup.style.top = top + "px";
/// X軸:視滑鼠在目標元素的水平位置,依比例。但不能讓彈出式視窗超過畫面寬度。
let left = rect.left + window.scrollX; // 目標元素的左緣
left += (event.clientX - rect.left)
* Math.max(rect.width - popup.offsetWidth, 0) / rect.width
; // 如果目標元素比彈出式視窗還要寬,那就依滑鼠在目標元素的相對位置來調整彈出式視窗的X軸位置。
if(left + popup.offsetWidth > document.body.clientWidth) // 不能讓彈出式視窗超過畫面寬度
left = document.body.clientWidth - popup.offsetWidth;
popup.style.left = Math.max(left, 0) + "px";
// 箭頭的位置:跟著滑鼠座標的X值,但不能超出彈出式視窗本身。
const arrowLeft = Math.min(
event.pageX - left - arrow.offsetWidth / 2, // 理想位置
popup.offsetWidth - arrow.offsetWidth - 8 // 彈出式視窗右緣,再扣掉原角框的範圍
);
arrow.style.marginLeft = Math.max(arrowLeft, 8) + "px";
},
getShadowRoot() {
let host = kongUtil.$('#LER-shadow-host');
if(!host) {
host = kongUtil.createElementFromJsonML([
'div', {
id: 'LER-shadow-host',
style: 'position: static; width: 0; height: 0;'
}
]);
document.body.append(host);
const root = host.attachShadow({mode: 'open'});
this.fetchText({resource: 'content_scripts/main.css'})
.then(css => {
root.append(kongUtil.createElementFromJsonML(
['style', css]
));
});
}
return host.shadowRoot;
}
});