content.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. const PANEL_ID = 'grammar-panel';
  2. const PANEL_STYLE_ID = 'grammar-panel-style';
  3. const PARAGRAPH_SELECTORS = 'p, article p, section p, div';
  4. let panelEl = null;
  5. function ensurePanelStyle() {
  6. if (document.getElementById(PANEL_STYLE_ID)) return;
  7. const style = document.createElement('style');
  8. style.id = PANEL_STYLE_ID;
  9. style.textContent = `
  10. .grammar-panel {
  11. position: fixed;
  12. right: 16px;
  13. bottom: 16px;
  14. width: 220px;
  15. padding: 12px;
  16. border-radius: 10px;
  17. box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
  18. background: #fff;
  19. font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  20. z-index: 2147483647;
  21. border: 1px solid rgba(15, 23, 42, 0.08);
  22. }
  23. .grammar-panel__header {
  24. display: flex;
  25. align-items: center;
  26. justify-content: space-between;
  27. margin-bottom: 10px;
  28. }
  29. .grammar-panel__title {
  30. font-size: 14px;
  31. font-weight: 600;
  32. margin: 0;
  33. color: #0f172a;
  34. }
  35. .grammar-panel__close {
  36. background: transparent;
  37. border: none;
  38. width: 10px;
  39. height: 20px;
  40. font-size: 16px;
  41. cursor: pointer;
  42. color: #475569;
  43. }
  44. .grammar-panel button {
  45. width: 100%;
  46. padding: 8px;
  47. margin-bottom: 6px;
  48. border: none;
  49. border-radius: 6px;
  50. font-size: 13px;
  51. cursor: pointer;
  52. background: #2563eb;
  53. color: white;
  54. transition: background 0.2s ease;
  55. }
  56. .grammar-panel button:hover {
  57. background: #1d4ed8;
  58. }
  59. .grammar-panel__status {
  60. min-height: 16px;
  61. font-size: 11px;
  62. color: #475569;
  63. margin-top: 4px;
  64. }
  65. .grammar-panel__status.error {
  66. color: #dc2626;
  67. }
  68. .grammar-panel__status.success {
  69. color: #0a8754;
  70. }
  71. `;
  72. document.head.appendChild(style);
  73. }
  74. function callBackend(payload) {
  75. return new Promise((resolve, reject) => {
  76. chrome.runtime.sendMessage(
  77. { type: 'GRAMMAR_API_REQUEST', ...payload },
  78. (response) => {
  79. if (chrome.runtime.lastError) {
  80. reject(new Error(chrome.runtime.lastError.message));
  81. return;
  82. }
  83. if (!response) {
  84. reject(new Error('后台未返回数据。'));
  85. return;
  86. }
  87. if (response.error) {
  88. reject(new Error(response.error));
  89. return;
  90. }
  91. resolve(response);
  92. }
  93. );
  94. });
  95. }
  96. function isParagraphElement(node) {
  97. return (
  98. node &&
  99. node.nodeType === Node.ELEMENT_NODE &&
  100. node.matches &&
  101. node.matches(PARAGRAPH_SELECTORS)
  102. );
  103. }
  104. function collectParagraphNodes(container) {
  105. if (!container || container.nodeType !== Node.ELEMENT_NODE) return [];
  106. const paragraphs = Array.from(container.querySelectorAll('p')).filter(
  107. (el) => el.innerText.trim()
  108. );
  109. if (paragraphs.length > 0) return paragraphs;
  110. return [container];
  111. }
  112. function buildParagraphTarget(nodes) {
  113. const usableNodes = nodes.filter(
  114. (node) => node && node.innerText && node.innerText.trim()
  115. );
  116. if (usableNodes.length === 0) {
  117. throw new Error('未找到可用段落文本。');
  118. }
  119. const originals = usableNodes.map((node) => node.innerHTML);
  120. const paragraphs = usableNodes.map((node) => node.innerText.trim());
  121. return {
  122. payload: { paragraphs },
  123. apply(htmlList) {
  124. const list = Array.isArray(htmlList) ? htmlList : [];
  125. usableNodes.forEach((node, idx) => {
  126. const html = list[idx];
  127. if (typeof html === 'string' && html.trim()) {
  128. node.innerHTML = html;
  129. }
  130. });
  131. },
  132. restore() {
  133. usableNodes.forEach((node, idx) => {
  134. node.innerHTML = originals[idx];
  135. });
  136. }
  137. };
  138. }
  139. function getSelectionTarget() {
  140. const selection = window.getSelection();
  141. if (!selection || selection.rangeCount === 0) {
  142. throw new Error('请先在页面中选择一段文本。');
  143. }
  144. const range = selection.getRangeAt(0);
  145. if (range.collapsed) {
  146. throw new Error('所选文本为空。');
  147. }
  148. const root =
  149. range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
  150. ? range.commonAncestorContainer
  151. : range.commonAncestorContainer.parentElement || document.body;
  152. const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
  153. acceptNode(node) {
  154. if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_SKIP;
  155. return isParagraphElement(node) && range.intersectsNode(node)
  156. ? NodeFilter.FILTER_ACCEPT
  157. : NodeFilter.FILTER_SKIP;
  158. }
  159. });
  160. const nodes = [];
  161. let current = walker.nextNode();
  162. while (current) {
  163. nodes.push(current);
  164. current = walker.nextNode();
  165. }
  166. if (nodes.length === 0) {
  167. const fallback =
  168. (range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
  169. ? range.commonAncestorContainer
  170. : range.commonAncestorContainer.parentElement) || document.body;
  171. nodes.push(fallback.closest(PARAGRAPH_SELECTORS) || fallback);
  172. }
  173. return buildParagraphTarget(nodes);
  174. }
  175. function getParagraphTarget() {
  176. const selection = window.getSelection();
  177. let node =
  178. selection && selection.rangeCount > 0
  179. ? selection.getRangeAt(0).commonAncestorContainer
  180. : document.activeElement;
  181. if (!node) {
  182. node = document.body;
  183. }
  184. if (node.nodeType === Node.TEXT_NODE) {
  185. node = node.parentElement;
  186. }
  187. const paragraph =
  188. node.closest(PARAGRAPH_SELECTORS) || document.querySelector('p') || document.body;
  189. return buildParagraphTarget([paragraph]);
  190. }
  191. function getArticleTarget() {
  192. const container =
  193. document.querySelector('article') ||
  194. document.querySelector('main') ||
  195. document.body;
  196. const nodes = collectParagraphNodes(container).filter(
  197. (node) => node.innerText.trim()
  198. );
  199. return buildParagraphTarget(nodes);
  200. }
  201. async function handleAnalyze(mode) {
  202. let targetGetter;
  203. switch (mode) {
  204. case 'selection':
  205. targetGetter = getSelectionTarget;
  206. break;
  207. case 'paragraph':
  208. targetGetter = getParagraphTarget;
  209. break;
  210. case 'article':
  211. targetGetter = getArticleTarget;
  212. break;
  213. default:
  214. throw new Error('未知的分析模式。');
  215. }
  216. const target = targetGetter();
  217. try {
  218. const response = await callBackend(target.payload);
  219. const htmlList =
  220. response.highlightedHtmls ||
  221. (response.highlightedHtml ? [response.highlightedHtml] : []);
  222. if (!htmlList || htmlList.length === 0) {
  223. throw new Error('后台未返回数据。');
  224. }
  225. target.apply(htmlList);
  226. return {};
  227. } catch (error) {
  228. target.restore?.();
  229. throw error;
  230. }
  231. }
  232. function setPanelStatus(message, type = '') {
  233. if (!panelEl) return;
  234. const status = panelEl.querySelector('.grammar-panel__status');
  235. if (!status) return;
  236. status.textContent = message || '';
  237. status.className = `grammar-panel__status ${type}`;
  238. }
  239. function setPanelDisabled(disabled) {
  240. if (!panelEl) return;
  241. panelEl.querySelectorAll('button[data-mode]').forEach((btn) => {
  242. btn.disabled = disabled;
  243. });
  244. }
  245. function createPanel() {
  246. ensurePanelStyle();
  247. if (panelEl) return panelEl;
  248. const panel = document.createElement('div');
  249. panel.id = PANEL_ID;
  250. panel.className = 'grammar-panel';
  251. panel.innerHTML = `
  252. <div class="grammar-panel__header">
  253. <p class="grammar-panel__title">Grammar Glow</p>
  254. <button class="grammar-panel__close" title="关闭">X</button>
  255. </div>
  256. <button data-mode="selection">分析选中文本</button>
  257. <button data-mode="paragraph">分析当前段落</button>
  258. <button data-mode="article">分析整篇文章</button>
  259. <div class="grammar-panel__status"></div>
  260. `;
  261. panel.querySelector('.grammar-panel__close')?.addEventListener('click', () => {
  262. panel.remove();
  263. panelEl = null;
  264. });
  265. panel.querySelectorAll('button[data-mode]').forEach((btn) => {
  266. btn.addEventListener('click', async () => {
  267. const mode = btn.dataset.mode;
  268. setPanelStatus('处理中...', '');
  269. setPanelDisabled(true);
  270. try {
  271. await handleAnalyze(mode);
  272. setPanelStatus('已完成高亮。', 'success');
  273. } catch (error) {
  274. setPanelStatus(error.message || '未知错误', 'error');
  275. } finally {
  276. setPanelDisabled(false);
  277. }
  278. });
  279. });
  280. document.body.appendChild(panel);
  281. panelEl = panel;
  282. return panel;
  283. }
  284. function togglePanel() {
  285. if (panelEl) {
  286. panelEl.remove();
  287. panelEl = null;
  288. return;
  289. }
  290. createPanel();
  291. }
  292. chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  293. if (message?.type === 'GRAMMAR_ANALYZE') {
  294. handleAnalyze(message.mode)
  295. .then(() => sendResponse({ success: true }))
  296. .catch((error) => {
  297. sendResponse({ error: error.message || '未知错误' });
  298. });
  299. return true;
  300. }
  301. if (message?.type === 'GRAMMAR_TOGGLE_PANEL') {
  302. togglePanel();
  303. }
  304. });