From 0cc41203c01bafb560e7fd4a2083df47f0316114 Mon Sep 17 00:00:00 2001 From: VET Date: Sat, 18 Apr 2026 15:26:26 +0700 Subject: [PATCH 01/12] feat(extension): Restore Antigravity snapshot onto master lineage Reattach the latest recovered extension implementation to the main history so the upgraded MV3 runtime, popup, settings, fixtures, and tests become the canonical repository state again. Keep IDE metadata untracked and drop tool-state directories from the restored tree. Constraint: Recovery source is unreachable stash commit ac325a0, not a live branch Constraint: Master must stay recoverable from pre-restore commit 9e434c9 Rejected: Apply stash in the active worktree | untracked .omx paths would collide Rejected: Restore tool-state directories | not part of the product deliverable Confidence: high Scope-risk: broad Reversibility: clean Directive: Treat this commit as the recovered Antigravity baseline before further cleanup Tested: node --test; node --check menu.js options.js background.js popup.js shared.js Tested: npx --yes web-ext lint --source-dir .; HTTP 200 for three fixture pages Not-tested: Full interactive browser run with the unpacked extension loaded --- .gitignore | 13 +- README.md | 62 + _locales/am/messages.json | 98 +- _locales/ar/messages.json | 98 +- _locales/bg/messages.json | 98 +- _locales/bn/messages.json | 98 +- _locales/ca/messages.json | 98 +- _locales/cs/messages.json | 98 +- _locales/da/messages.json | 98 +- _locales/de/messages.json | 98 +- _locales/el/messages.json | 98 +- _locales/en/messages.json | 95 +- _locales/en_GB/messages.json | 98 +- _locales/en_US/messages.json | 98 +- _locales/es/messages.json | 98 +- _locales/es_419/messages.json | 98 +- _locales/et/messages.json | 98 +- _locales/fa/messages.json | 98 +- _locales/fi/messages.json | 98 +- _locales/fil/messages.json | 98 +- _locales/fr/messages.json | 98 +- _locales/gu/messages.json | 98 +- _locales/hi/messages.json | 98 +- _locales/hr/messages.json | 98 +- _locales/hu/messages.json | 98 +- _locales/id/messages.json | 98 +- _locales/it/messages.json | 98 +- _locales/iw/messages.json | 98 +- _locales/ja/messages.json | 95 +- _locales/kn/messages.json | 98 +- _locales/ko/messages.json | 98 +- _locales/lt/messages.json | 98 +- _locales/lv/messages.json | 98 +- _locales/ml/messages.json | 98 +- _locales/mr/messages.json | 98 +- _locales/ms/messages.json | 98 +- _locales/nl/messages.json | 98 +- _locales/no/messages.json | 98 +- _locales/pl/messages.json | 98 +- _locales/pt_BR/messages.json | 98 +- _locales/pt_PT/messages.json | 98 +- _locales/ro/messages.json | 98 +- _locales/ru/messages.json | 98 +- _locales/sk/messages.json | 98 +- _locales/sl/messages.json | 98 +- _locales/sr/messages.json | 98 +- _locales/sv/messages.json | 98 +- _locales/sw/messages.json | 98 +- _locales/ta/messages.json | 98 +- _locales/te/messages.json | 98 +- _locales/th/messages.json | 98 +- _locales/tr/messages.json | 98 +- _locales/uk/messages.json | 98 +- _locales/vi/messages.json | 98 +- _locales/zh_CN/messages.json | 98 +- _locales/zh_TW/messages.json | 98 +- background.js | 181 +++ docs/browser-manual-test-checklist.md | 122 ++ fixtures/basic-copy.html | 86 ++ fixtures/editable-surfaces.html | 59 + fixtures/keyboard-shortcut.html | 51 + fixtures/selection-copy.html | 67 ++ fixtures/table-copy.html | 63 + manifest.json | 45 +- menu.js | 1530 ++++++++++++++++++++++++- options.css | 678 +++++++++++ options.html | 244 +++- options.js | 812 ++++++++++++- package.json | 9 + popup.html | 529 +++++++++ popup.js | 383 +++++++ shared.js | 506 ++++++++ tests/shared.test.js | 277 +++++ 73 files changed, 10847 insertions(+), 156 deletions(-) create mode 100644 background.js create mode 100644 docs/browser-manual-test-checklist.md create mode 100644 fixtures/basic-copy.html create mode 100644 fixtures/editable-surfaces.html create mode 100644 fixtures/keyboard-shortcut.html create mode 100644 fixtures/selection-copy.html create mode 100644 fixtures/table-copy.html create mode 100644 options.css create mode 100644 package.json create mode 100644 popup.html create mode 100644 popup.js create mode 100644 shared.js create mode 100644 tests/shared.test.js diff --git a/.gitignore b/.gitignore index 7d861d5..35697e6 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,15 @@ Network Trash Folder Temporary Items .apdisk -web-ext-artifacts \ No newline at end of file +web-ext-artifacts +# AI Context +.cobridge + +# .traerules +.traerules + +# .cursorrules +.cursorrules + +# GitHub Copilot Instructions +.github/copilot-instructions.md diff --git a/README.md b/README.md index a623d9f..758c7c8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,21 @@ Easy and fast copy tool. +## Features + +- Copy text from page elements with a configurable modifier key +- Hover preview overlay while holding the modifier +- Smart copy formatting for links and images +- Domain blacklist that disables both preview and copy behavior +- Quick popup controls for the current site +- Full tabbed settings center for General, Sites, Feedback, Language, and History +- User-selectable UI language override with English and Vietnamese +- Copy history panel with quick re-copy, pin, and delete actions +- Search and filter controls for local copy history +- Local analytics dashboard for copy, shortcut, block, domain, and selection activity +- Keyboard shortcut mode for hovered or focused targets +- Safe mode to skip rich-text editors and editable app surfaces + ## Download * [Add-ons for Firefox](https://addons.mozilla.org/ja/firefox/addon/copy-text-without-selecting/ "Copy Text with Alt-Click :: Add-ons for Firefox") @@ -14,6 +29,53 @@ What is needed, just "Alt key & Click" on the text! That's all! This alone, the text of the point you click will be copied. +## How It Works + +- `background.js` dynamically registers the content script and respects excluded domains +- `menu.js` handles hover preview, smart extraction, copy, native-copy tracking, and visual feedback +- `popup.html` / `popup.js` provide quick controls for the current site and common settings +- `options.html` / `options.js` expose the full tabbed settings management experience +- `shared.js` contains reusable settings and hostname utilities shared by runtime, popup, options, and tests +- local history and analytics are stored in `chrome.storage.local` + +## Settings + +- **Copy modifier**: `Alt`, `Ctrl`, or `Shift` +- **Hover preview**: toggles the dashed target overlay +- **Skip editable apps**: avoids contenteditable editors and rich text surfaces +- **Feedback duration**: controls how long the copy feedback remains visible, with fully custom timing +- **Excluded domains**: one hostname per line, matched against the host and its subdomains +- **Extension language**: `Auto`, `English`, or `Tiếng Việt` +- **Copy history size**: controls how many recent copied items remain available +- **History search/filter**: filter by source, mode, and domain from the options page +- **Local analytics**: summary cards and top domains, stored locally only +- **Keyboard shortcut mode**: lets browser shortcuts trigger copy without requiring a click + +## Test Fixtures + +The repo includes browser fixtures and a manual checklist: + +- `fixtures/basic-copy.html` +- `fixtures/editable-surfaces.html` +- `fixtures/keyboard-shortcut.html` +- `docs/browser-manual-test-checklist.md` + +Serve the fixtures over HTTP before testing: + +```bash +python -m http.server 4173 +``` + +## Development + +Run the repository tests with Node.js: + +```bash +npm test +``` + +The test suite uses the built-in `node:test` runner, so there are no external development dependencies to install. + ![Screenshot](https://addons.mozilla.org/user-media/previews/full/193/193185.png?modified=1622132342) ( Icon designed by: [Mouse Runner.com](http://www.mouserunner.com/ "Mouse Runner.com, Good Content, Free Resources") ) diff --git a/_locales/am/messages.json b/_locales/am/messages.json index 0eeb408..31c860b 100644 --- a/_locales/am/messages.json +++ b/_locales/am/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&ቅዳ" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/ar/messages.json b/_locales/ar/messages.json index fcd651c..f450764 100644 --- a/_locales/ar/messages.json +++ b/_locales/ar/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&نسخ" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/bg/messages.json b/_locales/bg/messages.json index 35b4b99..ad09ff0 100644 --- a/_locales/bg/messages.json +++ b/_locales/bg/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Копиране" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/bn/messages.json b/_locales/bn/messages.json index cec5b1b..ff771dd 100644 --- a/_locales/bn/messages.json +++ b/_locales/bn/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "©" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/ca/messages.json b/_locales/ca/messages.json index deb1885..7eb52bc 100644 --- a/_locales/ca/messages.json +++ b/_locales/ca/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Copia" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/cs/messages.json b/_locales/cs/messages.json index f1527c8..4eaeae9 100644 --- a/_locales/cs/messages.json +++ b/_locales/cs/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopírovat" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/da/messages.json b/_locales/da/messages.json index 5193741..22a7384 100644 --- a/_locales/da/messages.json +++ b/_locales/da/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopier" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/de/messages.json b/_locales/de/messages.json index f4dc777..88ec608 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopieren" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/el/messages.json b/_locales/el/messages.json index a0be949..9427fc1 100644 --- a/_locales/el/messages.json +++ b/_locales/el/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Αντιγραφή" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9e85220..6c4fc83 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4,5 +4,98 @@ }, "meta_key": { "message": "Copy Operation: " + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/en_GB/messages.json b/_locales/en_GB/messages.json index 605660f..b6f58bd 100644 --- a/_locales/en_GB/messages.json +++ b/_locales/en_GB/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Copy" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/en_US/messages.json b/_locales/en_US/messages.json index 605660f..b6f58bd 100644 --- a/_locales/en_US/messages.json +++ b/_locales/en_US/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Copy" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 86ade98..3dc59b2 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Copiar" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/es_419/messages.json b/_locales/es_419/messages.json index 86ade98..3dc59b2 100644 --- a/_locales/es_419/messages.json +++ b/_locales/es_419/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Copiar" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/et/messages.json b/_locales/et/messages.json index 23ce612..3e33319 100644 --- a/_locales/et/messages.json +++ b/_locales/et/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopeeri" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/fa/messages.json b/_locales/fa/messages.json index 3a0b8cc..099b908 100644 --- a/_locales/fa/messages.json +++ b/_locales/fa/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&کپی" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/fi/messages.json b/_locales/fi/messages.json index 55dbaee..3a6794c 100644 --- a/_locales/fi/messages.json +++ b/_locales/fi/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "K&opioi" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/fil/messages.json b/_locales/fil/messages.json index ff41fac..27795d6 100644 --- a/_locales/fil/messages.json +++ b/_locales/fil/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopyahin" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 8b78e62..722f9f8 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Copier" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/gu/messages.json b/_locales/gu/messages.json index dbc2d4e..55adf93 100644 --- a/_locales/gu/messages.json +++ b/_locales/gu/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&કૉપિ કરો" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index 51db32a..327c48c 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&प्रतिलिपि बनाएं" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/hr/messages.json b/_locales/hr/messages.json index 3e25d3d..f525291 100644 --- a/_locales/hr/messages.json +++ b/_locales/hr/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopiraj" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/hu/messages.json b/_locales/hu/messages.json index bd3c732..10a3012 100644 --- a/_locales/hu/messages.json +++ b/_locales/hu/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Másolás" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/id/messages.json b/_locales/id/messages.json index 6249a83..de96f54 100644 --- a/_locales/id/messages.json +++ b/_locales/id/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Salin" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/it/messages.json b/_locales/it/messages.json index deb1885..7eb52bc 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Copia" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/iw/messages.json b/_locales/iw/messages.json index 81852a7..9ed5867 100644 --- a/_locales/iw/messages.json +++ b/_locales/iw/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&העתק" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index f0e2478..27ca34d 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -4,5 +4,98 @@ }, "meta_key": { "message": "コピー操作: " + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/kn/messages.json b/_locales/kn/messages.json index cc89d4f..acdaf30 100644 --- a/_locales/kn/messages.json +++ b/_locales/kn/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&ನಕಲಿಸಿ" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 440e7d3..1bc4961 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "복사(&C)" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/lt/messages.json b/_locales/lt/messages.json index 992e3c4..e0a551c 100644 --- a/_locales/lt/messages.json +++ b/_locales/lt/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopijuoti" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/lv/messages.json b/_locales/lv/messages.json index 4269a09..17bdb7b 100644 --- a/_locales/lv/messages.json +++ b/_locales/lv/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "Ko&pēt" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/ml/messages.json b/_locales/ml/messages.json index 3420aaf..e08d83d 100644 --- a/_locales/ml/messages.json +++ b/_locales/ml/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&പകര്‍ത്തൂ" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/mr/messages.json b/_locales/mr/messages.json index 951dff8..6454010 100644 --- a/_locales/mr/messages.json +++ b/_locales/mr/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&कॉपी करा" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/ms/messages.json b/_locales/ms/messages.json index 6249a83..de96f54 100644 --- a/_locales/ms/messages.json +++ b/_locales/ms/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Salin" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/nl/messages.json b/_locales/nl/messages.json index 44204ee..b6ec11e 100644 --- a/_locales/nl/messages.json +++ b/_locales/nl/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopiëren" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/no/messages.json b/_locales/no/messages.json index 5193741..22a7384 100644 --- a/_locales/no/messages.json +++ b/_locales/no/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopier" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/pl/messages.json b/_locales/pl/messages.json index 664e422..4ef802e 100644 --- a/_locales/pl/messages.json +++ b/_locales/pl/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopiuj" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/pt_BR/messages.json b/_locales/pt_BR/messages.json index cb556a2..90378f1 100644 --- a/_locales/pt_BR/messages.json +++ b/_locales/pt_BR/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "Co&piar" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/pt_PT/messages.json b/_locales/pt_PT/messages.json index 86ade98..3dc59b2 100644 --- a/_locales/pt_PT/messages.json +++ b/_locales/pt_PT/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Copiar" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/ro/messages.json b/_locales/ro/messages.json index c95149a..d1c1edc 100644 --- a/_locales/ro/messages.json +++ b/_locales/ro/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Copiați" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index 7c0e360..d7771ea 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Копировать" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/sk/messages.json b/_locales/sk/messages.json index bb4c7ec..74e5fe4 100644 --- a/_locales/sk/messages.json +++ b/_locales/sk/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopírovať" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/sl/messages.json b/_locales/sl/messages.json index 3e25d3d..f525291 100644 --- a/_locales/sl/messages.json +++ b/_locales/sl/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopiraj" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/sr/messages.json b/_locales/sr/messages.json index 51e7204..dc445cb 100644 --- a/_locales/sr/messages.json +++ b/_locales/sr/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Копирај" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/sv/messages.json b/_locales/sv/messages.json index 633af6e..b69673b 100644 --- a/_locales/sv/messages.json +++ b/_locales/sv/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Kopiera" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/sw/messages.json b/_locales/sw/messages.json index 5d2da04..8d75fd7 100644 --- a/_locales/sw/messages.json +++ b/_locales/sw/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Nakili" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/ta/messages.json b/_locales/ta/messages.json index 736d3a7..84ada00 100644 --- a/_locales/ta/messages.json +++ b/_locales/ta/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&நகலெடு" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/te/messages.json b/_locales/te/messages.json index 914e993..1d5ec5a 100644 --- a/_locales/te/messages.json +++ b/_locales/te/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&కాపీ" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/th/messages.json b/_locales/th/messages.json index 3c6b80f..04d0a3e 100644 --- a/_locales/th/messages.json +++ b/_locales/th/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&คัดลอก" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/tr/messages.json b/_locales/tr/messages.json index c6468e1..17df02f 100644 --- a/_locales/tr/messages.json +++ b/_locales/tr/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "K&opyala" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/uk/messages.json b/_locales/uk/messages.json index c0fa7d8..52c7e12 100644 --- a/_locales/uk/messages.json +++ b/_locales/uk/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "&Копіювати" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/vi/messages.json b/_locales/vi/messages.json index 4148243..5e6694c 100644 --- a/_locales/vi/messages.json +++ b/_locales/vi/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "Sao &chép" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index e4c45b0..d0ec820 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "复制(&C)" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/_locales/zh_TW/messages.json b/_locales/zh_TW/messages.json index c99da1e..f42eee1 100644 --- a/_locales/zh_TW/messages.json +++ b/_locales/zh_TW/messages.json @@ -1,5 +1,101 @@ { "copy": { "message": "複製(&C)" + }, + "meta_key": { + "message": "Copy operation" + }, + "meta_key_help": { + "message": "Append mode uses Shift together with your chosen copy modifier." + }, + "excluded_domains_label": { + "message": "Excluded domains" + }, + "excluded_domains_help": { + "message": "Enter one domain per line. Excluded domains disable both hover previews and copy actions. Example: figma.com blocks figma.com and all of its subdomains." + }, + "preview_enabled_label": { + "message": "Hover preview" + }, + "preview_enabled_help": { + "message": "Show the dashed outline overlay while holding the copy modifier." + }, + "avoid_editable_label": { + "message": "Skip editable apps" + }, + "avoid_editable_help": { + "message": "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive." + }, + "toast_duration_label": { + "message": "Feedback duration" + }, + "toast_duration_help": { + "message": "Controls how long the floating feedback remains visible." + }, + "save_status_saved": { + "message": "Saved" + }, + "settings_eyebrow": { + "message": "Settings" + }, + "settings_title": { + "message": "Copy text with Alt-Click" + }, + "settings_intro": { + "message": "Configure how copy gestures, hover previews, and safe-mode protections behave across websites." + }, + "popup_eyebrow": { + "message": "Quick controls" + }, + "popup_title": { + "message": "Copy text with Alt-Click" + }, + "popup_subtitle": { + "message": "Tweak the current site and the most-used interaction settings without opening the full options page." + }, + "popup_site_label": { + "message": "Current site" + }, + "popup_modifier_label": { + "message": "Copy modifier" + }, + "popup_preview_label": { + "message": "Hover preview" + }, + "popup_preview_hint": { + "message": "Show the target overlay while the modifier key is held." + }, + "popup_safe_label": { + "message": "Skip editable apps" + }, + "popup_safe_hint": { + "message": "Avoid copying inside contenteditable editors and rich text surfaces." + }, + "popup_duration_label": { + "message": "Feedback duration" + }, + "popup_append_hint": { + "message": "Append mode stays available with Shift + modifier + Click." + }, + "popup_site_active": { + "message": "Active" + }, + "popup_site_excluded": { + "message": "Excluded" + }, + "popup_site_unsupported": { + "message": "Unsupported" + }, + "popup_site_unknown": { + "message": "This page is not scriptable" + }, + "popup_toggle_include": { + "message": "Allow this site" + }, + "popup_toggle_exclude": { + "message": "Exclude this site" + }, + "open_options": { + "message": "Open full settings" } -} \ No newline at end of file +} diff --git a/background.js b/background.js new file mode 100644 index 0000000..704c2d3 --- /dev/null +++ b/background.js @@ -0,0 +1,181 @@ +if (typeof importScripts === "function") { + importScripts("shared.js"); +} + +var utils = globalThis.CopyTextUtils; +var CONTENT_SCRIPT_ID = "copy-text-with-alt-click-content"; + +chrome.runtime.onInstalled.addListener(function () { + initializeExtension(); +}); + +chrome.runtime.onStartup.addListener(function () { + initializeExtension(); +}); + +chrome.storage.onChanged.addListener(function (changes, areaName) { + if (areaName == "sync") { + initializeExtension(); + } +}); + +chrome.commands.onCommand.addListener(function (command) { + triggerShortcutCopy(command); +}); + +async function initializeExtension() { + var settings = await ensureSettings(); + await syncContentScriptRegistration(settings); + await injectContentScriptsIntoOpenTabs(settings); + await trimHistory(settings.copyHistoryLimit); +} + +async function ensureSettings() { + var current = await chrome.storage.sync.get(utils.DEFAULT_SETTINGS); + var merged = utils.mergeSettings(current); + await chrome.storage.sync.set(merged); + return merged; +} + +async function syncContentScriptRegistration(settings) { + try { + await chrome.scripting.unregisterContentScripts({ ids: [CONTENT_SCRIPT_ID] }); + } catch (error) { + // Ignore when the content script was not registered yet. + } + + await chrome.scripting.registerContentScripts([{ + id: CONTENT_SCRIPT_ID, + matches: ["http://*/*", "https://*/*"], + excludeMatches: utils.buildExcludeMatches(settings.excludedDomains), + js: ["shared.js", "menu.js"], + runAt: "document_start", + persistAcrossSessions: true, + }]); +} + +async function injectContentScriptsIntoOpenTabs(settings) { + var tabs = await chrome.tabs.query({}); + + await Promise.all(tabs.map(async function (tab) { + if (!tab.id || !tab.url) { + return; + } + + var hostname = utils.getHostnameFromUrl(tab.url); + if (!hostname || utils.isExcludedHost(hostname, settings.excludedDomains)) { + return; + } + + try { + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["shared.js", "menu.js"], + }); + } catch (error) { + // Ignore tabs where scripting is not allowed. + } + })); +} + +async function triggerShortcutCopy(command) { + var settings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); + if (!settings.keyboardShortcutEnabled) { + return; + } + + if (command != "copy-focused-target") { + return; + } + + var tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + var activeTab = tabs[0]; + if (!activeTab || !activeTab.id || !activeTab.url) { + return; + } + + var hostname = utils.getHostnameFromUrl(activeTab.url); + if (!hostname || utils.isExcludedHost(hostname, settings.excludedDomains)) { + if (hostname && utils.isExcludedHost(hostname, settings.excludedDomains)) { + await saveAnalyticsEvent({ + type: "blockedExcluded", + hostname: hostname, + toastKind: "status", + }); + } + return; + } + + try { + await chrome.tabs.sendMessage(activeTab.id, { + type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", + }); + } catch (error) { + try { + await chrome.scripting.executeScript({ + target: { tabId: activeTab.id }, + files: ["shared.js", "menu.js"], + }); + + await chrome.tabs.sendMessage(activeTab.id, { + type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", + }); + } catch (secondError) { + // Ignore when the page does not allow messaging or injection. + } + } +} + +async function trimHistory(limit) { + var current = await chrome.storage.local.get({ copyHistory: [] }); + var history = Array.isArray(current.copyHistory) ? current.copyHistory : []; + + // Enforce entry count limit + if (history.length > limit) { + history = history.slice(0, limit); + } + + // Enforce storage size guard (keep under 4MB to leave room for analytics/other data) + var maxBytes = 4 * 1024 * 1024; + var serialized = JSON.stringify(history); + while (serialized.length > maxBytes && history.length > 1) { + // Remove the oldest non-pinned entry first + var indexToRemove = -1; + for (var i = history.length - 1; i >= 0; i--) { + if (!history[i].pinned) { + indexToRemove = i; + break; + } + } + if (indexToRemove === -1) { + // All pinned, remove last one anyway + indexToRemove = history.length - 1; + } + history.splice(indexToRemove, 1); + serialized = JSON.stringify(history); + } + + await chrome.storage.local.set({ copyHistory: history }); +} + +// Schedule periodic history cleanup every 6 hours +try { + chrome.alarms.create("copy-text-history-cleanup", { periodInMinutes: 360 }); + chrome.alarms.onAlarm.addListener(function (alarm) { + if (alarm.name === "copy-text-history-cleanup") { + chrome.storage.sync.get(utils.DEFAULT_SETTINGS, function (items) { + var settings = utils.mergeSettings(items); + trimHistory(settings.copyHistoryLimit); + }); + } + }); +} catch (error) { + // Alarms API may not be available in all contexts +} + +async function saveAnalyticsEvent(event) { + var current = await chrome.storage.local.get({ copyAnalytics: utils.DEFAULT_ANALYTICS }); + var nextAnalytics = utils.recordAnalyticsEvent(current.copyAnalytics, event || {}); + await chrome.storage.local.set({ copyAnalytics: nextAnalytics }); +} + diff --git a/docs/browser-manual-test-checklist.md b/docs/browser-manual-test-checklist.md new file mode 100644 index 0000000..c8eeb12 --- /dev/null +++ b/docs/browser-manual-test-checklist.md @@ -0,0 +1,122 @@ +# Browser Manual Test Checklist + +## Setup + +1. Load the extension unpacked in Chrome or Edge. +2. Open `chrome://extensions/shortcuts` and confirm the extension commands are visible. +3. Serve the fixtures with a local HTTP server: + +```bash +python -m http.server 4173 +``` + +4. Open: + - `http://localhost:4173/fixtures/basic-copy.html` + - `http://localhost:4173/fixtures/editable-surfaces.html` + - `http://localhost:4173/fixtures/keyboard-shortcut.html` + - `http://localhost:4173/fixtures/selection-copy.html` + - `http://localhost:4173/fixtures/table-copy.html` + +## Options Page + +- [ ] Tabs switch correctly: General, Sites, Feedback, Language, History +- [ ] `Feedback duration` slider and number input stay synchronized +- [ ] `Feedback duration` accepts custom values outside the old preset-only flow +- [ ] `Extension language` switches between Auto, English, and Tiếng Việt +- [ ] Excluded domains can be added one-by-one +- [ ] Bulk domain apply updates the rendered domain list +- [ ] Domain remove action works +- [ ] Copy history limit saves correctly +- [ ] Search history finds entries by text and hostname +- [ ] Source filter works for click / shortcut / history replay +- [ ] Mode filter works for copy +- [ ] Domain filter is populated from stored history hosts +- [ ] Analytics cards update after new copy actions +- [ ] Reset analytics clears summary metrics +- [ ] Clear history button removes all history rows + +## Popup + +- [ ] Popup shows current hostname for normal websites +- [ ] Popup shows unsupported state on non-scriptable pages +- [ ] Toggle current site excludes/includes the active domain +- [ ] Popup recent history list renders entries after copying +- [ ] `Copy again` re-copies a history item +- [ ] Popup status text confirms successful actions + +## Basic Copy + +- [ ] Plain paragraph copies visible text +- [ ] Native `Ctrl+C` / browser copy on selected text creates a history item with native source +- [ ] Link copies as Markdown `[text](href)` +- [ ] Image copies `src` or `alt` +- [ ] Input copies `value` +- [ ] Textarea copies multiline value +- [ ] Select copies selected option text +- [ ] Copy feedback highlight appears +- [ ] Floating feedback toast appears and respects the configured duration + +## Selection-first + +- [ ] Selecting part of a paragraph copies only the selected text +- [ ] Selecting part of a link copies only the selected text, not the whole link container +- [ ] Selecting part of a code block ignores decorative line-number UI +- [ ] Hover preview hugs the selection/deep target rather than a broad parent container +- [ ] Selection in editable surfaces is skipped when safe mode is enabled + +## Table TSV + +- [ ] Clicking inside a table without a text selection exports the whole table as TSV +- [ ] Hidden cells are excluded from TSV output +- [ ] Multiline content inside a cell is normalized into spreadsheet-friendly text + +## Hover Preview + +- [ ] Holding the configured modifier shows preview overlay +- [ ] Releasing the modifier hides preview instantly +- [ ] Scroll and resize keep the preview aligned +- [ ] Excluded domains suppress hover preview + +## Safe Mode / Editable Surfaces + +- [ ] With `Skip editable apps` enabled, contenteditable areas do not preview or copy +- [ ] With `Skip editable apps` disabled, contenteditable areas can be targeted +- [ ] Regular text outside editable surfaces still works in both modes + +## Keyboard Shortcut Mode + +- [ ] Shortcut copies hovered target without clicking +- [ ] Shortcut copies focused element when nothing is hovered +- [ ] Disabling keyboard shortcut mode in settings suppresses command behavior + +## History + +- [ ] New copy actions create local history entries +- [ ] Native copy actions are labeled as native source +- [ ] History respects the configured item limit +- [ ] Shortcut-triggered copies are labeled as shortcut source +- [ ] History replay actions are labeled as history source +- [ ] Clearing history empties both popup and options history views +- [ ] History replay actions also refresh analytics + +## Excluded Domains + +- [ ] Excluding `localhost` (or another test host) disables both preview and copy +- [ ] Including the host again restores normal behavior +- [ ] Subdomain matching works as expected for excluded roots + +## Language + +- [ ] English override updates popup and options strings +- [ ] Vietnamese override updates popup and options strings +- [ ] Auto mode falls back cleanly without broken labels + +## Analytics + +- [ ] Total actions increases after normal copies +- [ ] Native copies increases after selected-text `Ctrl+C` +- [ ] Selection-first copies increases after selection-priority copy flows +- [ ] Shortcut usage increases after shortcut copy +- [ ] Blocked attempts increases after excluded-domain or editable-surface blocks +- [ ] Toast events increases after copy/status feedback is shown +- [ ] Top domains list reflects the busiest hosts diff --git a/fixtures/basic-copy.html b/fixtures/basic-copy.html new file mode 100644 index 0000000..f6605c5 --- /dev/null +++ b/fixtures/basic-copy.html @@ -0,0 +1,86 @@ + + + + + + Copy Extension Fixture - Basic + + + +

Basic copy scenarios

+

Use this page to validate plain text, link Markdown conversion, image extraction, and form control values.

+ +
+
+

Plain text

+

Copying this paragraph should capture the visible text exactly as displayed on the page.

+
+ +
+

Links

+

+ This card contains a + documentation link + that should copy as Markdown. +

+
+ +
+

Images

+ Workspace with laptop and notebook +
+ +
+

Input controls

+ + + +
+
+ + diff --git a/fixtures/editable-surfaces.html b/fixtures/editable-surfaces.html new file mode 100644 index 0000000..4e9efad --- /dev/null +++ b/fixtures/editable-surfaces.html @@ -0,0 +1,59 @@ + + + + + + Copy Extension Fixture - Editable Surfaces + + + +

Editable surface scenarios

+

Use this page to validate the “Skip editable apps” setting and ensure keyboard shortcuts behave safely inside editors.

+ +
+
+

Contenteditable block

+
+ This is a contenteditable block. When safe mode is enabled, hover preview and copy gestures should be skipped here. +
+
+ +
+

Role textbox

+
+ This simulates a richer editor surface that should be ignored in safe mode. +
+
+ +
+

Regular text outside editor

+

Copy on this paragraph should still work even when safe mode is enabled.

+
+
+ + diff --git a/fixtures/keyboard-shortcut.html b/fixtures/keyboard-shortcut.html new file mode 100644 index 0000000..146f478 --- /dev/null +++ b/fixtures/keyboard-shortcut.html @@ -0,0 +1,51 @@ + + + + + + Copy Extension Fixture - Keyboard Shortcut + + + +

Keyboard shortcut scenarios

+

Use the browser shortcut configured for the extension to verify hover-target copy, focused-element copy, and append shortcut behavior.

+ +
+

Hovered target

+

Hover this paragraph, then trigger the keyboard shortcut. It should copy this paragraph even without clicking.

+
+ +
+

Focused input

+ +
+ +
+

Focused button text

+ +
+ + diff --git a/fixtures/selection-copy.html b/fixtures/selection-copy.html new file mode 100644 index 0000000..0cfc980 --- /dev/null +++ b/fixtures/selection-copy.html @@ -0,0 +1,67 @@ + + + + + + Copy Extension Fixture - Selection First + + + +

Selection-first scenarios

+

Select text in each section, then use the extension action to confirm that the selection wins over the broader element target.

+ +
+

Plain selection

+

Select only a few words in this paragraph. The extension should copy exactly the selected range, not the whole paragraph.

+
+ +
+

Selection inside a link

+

+ Select only part of this link text + and verify the extension prefers the selected substring. +

+
+ +
+

Selection inside code

+
1 const price = 25;
+2 const total = price * 2;
+3 console.log(total);
+
+ +
+

Selection inside editable content

+
+ Select text here to verify how selection behaves when safe mode is on or off. +
+
+ + diff --git a/fixtures/table-copy.html b/fixtures/table-copy.html new file mode 100644 index 0000000..76e3649 --- /dev/null +++ b/fixtures/table-copy.html @@ -0,0 +1,63 @@ + + + + + + Copy Extension Fixture - Table TSV + + + +

Table extraction scenarios

+

Click inside the table without selecting text to verify whole-table TSV export.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProductOwnerStatus
Editor SuiteAnaBeta
Analytics HubMarcoLive
Docs CloudTrang +
SEA Region
+
PlanningShould not copy
+ + diff --git a/manifest.json b/manifest.json index e24db56..ee94196 100644 --- a/manifest.json +++ b/manifest.json @@ -1,19 +1,27 @@ { "name": "Copy text with Alt-Click", - "version": "2.2.0", + "version": "2.4.0", "manifest_version": 3, "description": "Copy text with Alt-Click. Easy and fast copy tool.", "default_locale": "en", "browser_specific_settings": { "gecko": { - "id": "@copy-text-without-selecting" + "id": "@copy-text-without-selecting", + "data_collection_permissions": { + "required": [ + "none" + ] + } } }, "permissions" : [ + "activeTab", + "scripting", "storage", - "clipboardWrite" + "clipboardWrite", + "alarms" ], "host_permissions": [ "http://*/*", @@ -24,19 +32,26 @@ "48" : "icon.png" }, - "content_scripts": [ - { - "matches": [ - "http://*/*", - "https://*/*" - ], - "js": [ - "menu.js" - ], - "run_at" : "document_start" - } + "background": { + "scripts": [ + "shared.js", + "background.js" ], + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html" + }, + "commands": { + "copy-focused-target": { + "suggested_key": { + "default": "Alt+Shift+C" + }, + "description": "Copy hovered or focused target" + } + }, "options_ui": { - "page": "options.html" + "page": "options.html", + "open_in_tab": true } } diff --git a/menu.js b/menu.js index 2f0b2c8..b4e5666 100644 --- a/menu.js +++ b/menu.js @@ -1,98 +1,1510 @@ (function () { - var metaKey; + if (globalThis.__copyTextWithAltClickContentScriptLoaded) { + return; + } + + globalThis.__copyTextWithAltClickContentScriptLoaded = true; + + var utils = globalThis.CopyTextUtils; + if (!utils) { + console.error("CopyTextUtils is not available."); + return; + } - updateMetaKey(); + var settings = utils.mergeSettings(); + var overlayState; + var hoverState = { + hoveredElement: null, + previewModifierActive: false, + previewAnimationFrame: 0, + pointerPageX: null, + pointerPageY: null, + pointerClientX: null, + pointerClientY: null, + scopeLevel: 0, + scopeAnchorClientX: null, + scopeAnchorClientY: null, + lastRenderedTarget: null, + }; + var suppressNativeCopyTracking = false; + + updateSettings(); chrome.storage.onChanged.addListener(function (changes, areaName) { if (areaName == "sync") { - updateMetaKey(); + updateSettings(); } - }) + }); - document.addEventListener("click", function (event) { - if ((event.altKey && metaKey == "Alt") - || (event.ctrlKey && metaKey == "Ctrl") - || (event.shiftKey && metaKey == "Shift")) { - copyCommand(event.target); - event.preventDefault(); + chrome.runtime.onMessage.addListener(function (message) { + if (!message || message.type != "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT") { + return; + } + + if (isCurrentHostExcluded() || !settings.keyboardShortcutEnabled) { + if (isCurrentHostExcluded()) { + saveAnalyticsEvent({ + type: "blockedExcluded", + hostname: window.location.hostname, + toastKind: "status", + }); + } + return; + } + + var shortcutTarget = resolveShortcutTarget(); + if (!shortcutTarget) { + saveAnalyticsEvent({ + toastKind: "status", + hostname: window.location.hostname, + }); + showStatusToast(t("shortcut_unavailable", "No hovered or focused target to copy.")); + return; } - }, false); - function updateMetaKey() { - chrome.storage.sync.get({ - metaKey: 'Alt', - }, function (items) { - metaKey = items.metaKey; + if (shouldIgnoreElement(shortcutTarget)) { + saveAnalyticsEvent({ + type: "editableSkipped", + hostname: window.location.hostname, + toastKind: "status", + }); + showStatusToast(t("unsupported_surface_status", "Editing surface skipped")); + return; + } + + copyCommand(shortcutTarget, "shortcut", { + preferSelection: true, + }).catch(function (error) { + console.error("Shortcut copy failed.", error); }); - }; + }); + + document.addEventListener("click", handleClick, false); + document.addEventListener("mousemove", handleMouseMove, true); + document.addEventListener("mouseover", handleMouseOver, true); + document.addEventListener("mouseout", handleMouseOut, true); + document.addEventListener("copy", handleNativeCopy, true); + document.addEventListener("keydown", handleModifierChange, true); + document.addEventListener("keyup", handleModifierChange, true); + document.addEventListener("wheel", handleWheel, { capture: true, passive: false }); + document.addEventListener("scroll", handleViewportChange, true); + document.addEventListener("visibilitychange", handleVisibilityChange, true); + window.addEventListener("resize", handleViewportChange); + + function handleClick(event) { + var copyMode = utils.getCopyMode(settings.metaKey, event); + if (!copyMode || isCurrentHostExcluded()) { + if (copyMode && isCurrentHostExcluded()) { + saveAnalyticsEvent({ + type: "blockedExcluded", + hostname: window.location.hostname, + }); + } + return; + } + + if (shouldIgnoreElement(event.target)) { + saveAnalyticsEvent({ + type: "editableSkipped", + hostname: window.location.hostname, + }); + return; + } + + var targetToCopy = hoverState.lastRenderedTarget; + + syncPointerState(event); + + if (targetToCopy) { + executePrecisionCopy(targetToCopy, "click").catch(function (error) { + console.error("Copy failed.", error); + }); + } else { + var composedTarget = event.composedPath ? event.composedPath()[0] : event.target; + var deepTarget = getElementNode(pierceShadowDOM(composedTarget, event.clientX, event.clientY)); + copyCommand(deepTarget, "click", { + clientX: event.clientX, + clientY: event.clientY, + preferSelection: true, + }).catch(function (error) { + console.error("Copy failed.", error); + }); + } + + event.preventDefault(); + } + + function handleMouseMove(event) { + // Fast path: only track pointer coordinates when no modifier is held + if (!utils.isPrimaryModifierPressed(settings.metaKey, event)) { + hoverState.pointerClientX = event.clientX; + hoverState.pointerClientY = event.clientY; + hoverState.pointerPageX = event.pageX; + hoverState.pointerPageY = event.pageY; + hoverState.previewModifierActive = false; + return; + } + + syncPointerState(event); + var composedTarget = event.composedPath ? event.composedPath()[0] : event.target; + hoverState.hoveredElement = getElementNode(pierceShadowDOM(composedTarget, event.clientX, event.clientY)); + hoverState.previewModifierActive = true; + hoverState.scopeLevel = 0; + + if (shouldShowPreview()) { + schedulePreviewUpdate(); + } else { + hidePreview(); + } + } + + function handleMouseOver(event) { + syncPointerState(event); + hoverState.hoveredElement = getElementNode(event.target); + hoverState.previewModifierActive = utils.isPrimaryModifierPressed(settings.metaKey, event); + + if (shouldShowPreview()) { + schedulePreviewUpdate(); + } + } + + function handleMouseOut(event) { + if (!event.relatedTarget) { + hoverState.hoveredElement = null; + hidePreview(); + return; + } + + hoverState.hoveredElement = getElementNode(event.relatedTarget); + + if (shouldShowPreview()) { + schedulePreviewUpdate(); + } else if (!hoverState.previewModifierActive) { + hidePreview(); + } + } + + function handleNativeCopy() { + if (suppressNativeCopyTracking || isCurrentHostExcluded()) { + return; + } + + var copiedText = getNativeCopiedText(); + if (!copiedText) { + return; + } + + saveAnalyticsEvent({ + type: "nativeCopy", + hostname: window.location.hostname, + }); + saveHistory(copiedText, "copied", "native"); + } + + function handleModifierChange(event) { + if (!utils.isModifierKeyEvent(event)) { + return; + } + + hoverState.previewModifierActive = utils.isPrimaryModifierPressed(settings.metaKey, event); + + if (!hoverState.previewModifierActive) { + hoverState.scopeLevel = 0; + } + + if (!hoverState.hoveredElement && hoverState.pointerClientX !== null && hoverState.pointerClientY !== null) { + var pointElement = document.elementFromPoint(hoverState.pointerClientX, hoverState.pointerClientY); + hoverState.hoveredElement = getElementNode(pierceShadowDOM(pointElement, hoverState.pointerClientX, hoverState.pointerClientY)); + } + + if (shouldShowPreview()) { + schedulePreviewUpdate(); + } else { + hidePreview(); + } + } + + function handleViewportChange() { + if (shouldShowPreview()) { + schedulePreviewUpdate(); + } + } + + function handleVisibilityChange() { + if (document.hidden) { + hidePreview(); + } + } + + function handleWheel(event) { + if (!hoverState.previewModifierActive || !shouldShowPreview()) { + return; + } + + event.preventDefault(); + + var delta = event.deltaY < 0 ? 1 : -1; + var nextLevel = Math.max(0, Math.min(3, hoverState.scopeLevel + delta)); + if (nextLevel === hoverState.scopeLevel) { + return; + } + + hoverState.scopeLevel = nextLevel; + hoverState.scopeAnchorClientX = hoverState.pointerClientX; + hoverState.scopeAnchorClientY = hoverState.pointerClientY; + schedulePreviewUpdate(); + } + + function pierceShadowDOM(element, x, y) { + if (!element) { + return element; + } + var root = element.shadowRoot; + if (!root) { + return element; + } + var deeper = root.elementFromPoint(x, y); + if (!deeper || deeper === element) { + return element; + } + return pierceShadowDOM(deeper, x, y); + } + + function syncPointerState(event) { + hoverState.pointerPageX = event.pageX; + hoverState.pointerPageY = event.pageY; + hoverState.pointerClientX = event.clientX; + hoverState.pointerClientY = event.clientY; + } + + + + var SCOPE_WORD = 0; + var SCOPE_SENTENCE = 1; + var SCOPE_PARAGRAPH = 2; + var SCOPE_CONTAINER = 3; + + function resolveScopedTarget(sourceNode, clientX, clientY, level) { + if (level <= SCOPE_WORD) { + return null; + } + + var caretRange = getCaretRangeAtPoint(clientX, clientY); + if (!caretRange) { + return null; + } + + var textNode = caretRange.startContainer; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + return null; + } + + var fullText = textNode.textContent || ""; + + if (level === SCOPE_SENTENCE) { + var sentenceRange = expandToSentence(textNode, caretRange.startOffset); + if (sentenceRange) { + return { + kind: "scope", + node: textNode, + range: sentenceRange, + rect: getRangeBoundingRect(sentenceRange), + text: sentenceRange.toString().trim(), + }; + } + } + + if (level === SCOPE_PARAGRAPH) { + var paragraphElement = getElementNode(textNode); + while (paragraphElement && paragraphElement !== document.body) { + var display = window.getComputedStyle ? window.getComputedStyle(paragraphElement).display : ""; + if (display === "block" || display === "list-item" || display === "flex" || paragraphElement.nodeName === "P" || paragraphElement.nodeName === "LI") { + break; + } + paragraphElement = paragraphElement.parentElement; + } + if (paragraphElement && paragraphElement !== document.body) { + return createElementTarget(paragraphElement); + } + } + + if (level === SCOPE_CONTAINER) { + var container = getElementNode(textNode); + if (container && container.parentElement && container.parentElement !== document.body && container.parentElement !== document.documentElement) { + return createElementTarget(container.parentElement); + } + } + + return null; + } + + function expandToSentence(textNode, offset) { + var text = textNode.textContent || ""; + if (!text.trim()) { + return null; + } + + var sentenceBreaks = /[.!?。!?]+[\s]*/g; + var sentences = []; + var lastEnd = 0; + var match; + while ((match = sentenceBreaks.exec(text)) !== null) { + sentences.push({ start: lastEnd, end: match.index + match[0].length }); + lastEnd = match.index + match[0].length; + } + if (lastEnd < text.length) { + sentences.push({ start: lastEnd, end: text.length }); + } + + if (sentences.length === 0) { + sentences.push({ start: 0, end: text.length }); + } + + var target = sentences[0]; + for (var i = 0; i < sentences.length; i++) { + if (offset >= sentences[i].start && offset <= sentences[i].end) { + target = sentences[i]; + break; + } + } + + var range = document.createRange(); + range.setStart(textNode, target.start); + range.setEnd(textNode, Math.min(target.end, text.length)); + return range; + } + + function updateSettings() { + chrome.storage.sync.get(utils.DEFAULT_SETTINGS, function (items) { + settings = utils.mergeSettings(items); + + if (isCurrentHostExcluded() || !hoverState.previewModifierActive) { + hidePreview(); + } else if (shouldShowPreview()) { + schedulePreviewUpdate(); + } + }); + } + + function resolvePrecisionTarget(sourceNode, options) { + var context = options || {}; + var sourceElement = getElementNode(sourceNode); + var clientX = Number.isFinite(context.clientX) ? context.clientX : hoverState.pointerClientX; + var clientY = Number.isFinite(context.clientY) ? context.clientY : hoverState.pointerClientY; + + if (context.preferSelection !== false) { + var selectionTarget = getSelectionTarget(sourceElement, clientX, clientY); + if (selectionTarget) { + return selectionTarget; + } + } + + // Scope expansion takes priority when active + var scopeClientX = hoverState.scopeAnchorClientX !== null ? hoverState.scopeAnchorClientX : clientX; + var scopeClientY = hoverState.scopeAnchorClientY !== null ? hoverState.scopeAnchorClientY : clientY; + if (hoverState.scopeLevel > SCOPE_WORD) { + var scopedTarget = resolveScopedTarget(sourceElement, scopeClientX, scopeClientY, hoverState.scopeLevel); + if (scopedTarget) { + return scopedTarget; + } + } + + var deepTextTarget = getDeepTextTarget(clientX, clientY); + var fallbackElement = getDeepElementTarget(sourceElement, clientX, clientY); + + var preliminaryTarget = deepTextTarget || createElementTarget(fallbackElement); + if (!preliminaryTarget) { + return null; + } + + var context = resolveExtractionContext(preliminaryTarget); + if (!context) { + return preliminaryTarget; + } + + switch (context.kind) { + case "selection": + case "scope": + return context; + case "table": + return { kind: "table", table: context.table, rect: context.table.getBoundingClientRect(), node: context.table }; + case "code": + return { kind: "code", container: context.container, rect: context.container.getBoundingClientRect(), node: context.container }; + case "list": + return { kind: "list", container: context.container, rect: context.container.getBoundingClientRect(), node: context.container }; + case "link": + return { kind: "link", anchor: context.anchor, rect: context.anchor.getBoundingClientRect(), node: context.anchor }; + case "image": + return { kind: "image", element: context.element, rect: context.element.getBoundingClientRect(), node: context.element }; + case "control": + return { kind: "control", element: context.element, rect: context.element.getBoundingClientRect(), node: context.element }; + case "text": + var rect = null; + if (context.node && context.node.nodeType === Node.TEXT_NODE) { + var textRange = document.createRange(); + textRange.selectNodeContents(context.node); + rect = getRangeBoundingRect(textRange); + } else if (context.node && context.node.getBoundingClientRect) { + rect = context.node.getBoundingClientRect(); + } + return { kind: "text", node: context.node, rect: rect || preliminaryTarget.rect }; + default: + return preliminaryTarget; + } + } + + function getSelectionTarget(sourceElement, clientX, clientY) { + var selection = window.getSelection ? window.getSelection() : null; + if (selection && selection.rangeCount && !selection.isCollapsed) { + var selectionText = String(selection.toString() || "").trim(); + if (selectionText) { + var range = selection.getRangeAt(0).cloneRange(); + var rect = getRangeBoundingRect(range); + var commonNode = selection.anchorNode || range.commonAncestorContainer; + if ((!Number.isFinite(clientX) || !Number.isFinite(clientY)) || isPointInsideRect(rect, clientX, clientY) || (sourceElement && commonNode && sourceElement.contains(getElementNode(commonNode)))) { + return { + kind: "selection", + range: range, + rect: rect, + text: selectionText, + node: commonNode, + }; + } + } + } + + var activeElement = document.activeElement; + if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { + var start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; + var end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; + if (end > start) { + var selectedText = String(activeElement.value || "").slice(start, end).trim(); + if (selectedText && (!sourceElement || sourceElement === activeElement || activeElement.contains(sourceElement))) { + return { + kind: "selection", + rect: activeElement.getBoundingClientRect(), + text: selectedText, + node: activeElement, + }; + } + } + } + + return null; + } + + function getDeepTextTarget(clientX, clientY) { + if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return null; + } + + var range = getCaretRangeAtPoint(clientX, clientY); + if (!range) { + return null; + } + + var node = range.startContainer; + if (node && node.nodeType == Node.TEXT_NODE && String(node.textContent || "").trim() && isNodeVisible(node)) { + var textRange = document.createRange(); + textRange.selectNodeContents(node); + return { + kind: "text", + node: node, + rect: getRangeBoundingRect(textRange), + }; + } + + return null; + } + + function getDeepElementTarget(sourceElement, clientX, clientY) { + var element = Number.isFinite(clientX) && Number.isFinite(clientY) + ? pierceShadowDOM(document.elementFromPoint(clientX, clientY), clientX, clientY) + : sourceElement; + return getClosestMeaningfulElement(element || sourceElement); + } + + function getCaretRangeAtPoint(clientX, clientY) { + if (document.caretPositionFromPoint) { + var position = document.caretPositionFromPoint(clientX, clientY); + if (position && position.offsetNode) { + var range = document.createRange(); + range.setStart(position.offsetNode, position.offset); + range.setEnd(position.offsetNode, position.offset); + return range; + } + } + + if (document.caretRangeFromPoint) { + return document.caretRangeFromPoint(clientX, clientY); + } + + return null; + } + + function getClosestMeaningfulElement(element) { + var current = getElementNode(element); + while (current && current !== document.body && current !== document.documentElement) { + if (isNodeVisible(current) && hasMeaningfulText(current)) { + return current; + } + current = current.parentElement; + } + return getElementNode(element); + } + + function createElementTarget(element) { + if (!element || !isNodeVisible(element)) { + return null; + } + + return { + kind: "element", + node: element, + rect: element.getBoundingClientRect(), + }; + } + + function hasMeaningfulText(node) { + if (!node) { + return false; + } - function getText(node) { if (node.nodeType == Node.TEXT_NODE) { - return getText(node.parentNode); + return !!String(node.textContent || "").trim(); + } + + var tagName = String(node.nodeName || "").toUpperCase(); + if (tagName == "IMG" || tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "SELECT") { + return true; + } + + return !!collectVisibleText(node).trim(); + } + + function isNodeVisible(node) { + var current = node.nodeType == Node.TEXT_NODE ? node.parentElement : getElementNode(node); + while (current && current !== document.documentElement) { + if (current.getAttribute && current.getAttribute("aria-hidden") == "true") { + return false; + } + + var style = window.getComputedStyle ? window.getComputedStyle(current) : null; + if (style && (style.display == "none" || style.visibility == "hidden" || Number(style.opacity) === 0)) { + return false; + } + + current = current.parentElement; + } + + return true; + } + + function getRangeBoundingRect(range) { + if (!range) { + return null; + } + + var rect = range.getBoundingClientRect(); + if (rect && rect.width > 0 && rect.height > 0) { + return rect; + } + + var rects = range.getClientRects ? Array.from(range.getClientRects()) : []; + if (!rects.length) { + return rect; + } + + return rects.reduce(function (acc, current) { + if (!acc) { + return current; + } + + var left = Math.min(acc.left, current.left); + var top = Math.min(acc.top, current.top); + var right = Math.max(acc.right, current.right); + var bottom = Math.max(acc.bottom, current.bottom); + return { + left: left, + top: top, + right: right, + bottom: bottom, + width: right - left, + height: bottom - top, + }; + }, null); + } + + function isPointInsideRect(rect, clientX, clientY) { + if (!rect || !Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return false; + } + return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom; + } + + function isCurrentHostExcluded() { + return utils.isExcludedHost(window.location.hostname, settings.excludedDomains); + } + + function t(key, fallback) { + return utils.translate(settings, key, fallback, chrome.i18n && chrome.i18n.getUILanguage ? chrome.i18n.getUILanguage() : ""); + } + + function shouldIgnoreElement(node) { + return settings.avoidEditable && utils.isEditableSurface(getElementNode(node)); + } + + function shouldShowPreview() { + return settings.previewEnabled + && hoverState.previewModifierActive + && !isCurrentHostExcluded() + && !!hoverState.hoveredElement + && hoverState.hoveredElement.isConnected + && !shouldIgnoreElement(hoverState.hoveredElement); + } + + function schedulePreviewUpdate() { + if (!shouldShowPreview()) { + hidePreview(); + return; + } + + if (hoverState.previewAnimationFrame) { + return; + } + + hoverState.previewAnimationFrame = window.requestAnimationFrame(function () { + hoverState.previewAnimationFrame = 0; + renderPreview(); + }); + } + + function renderPreview() { + if (!shouldShowPreview()) { + hidePreview(); + return; + } + + var precisionTarget = resolvePrecisionTarget(hoverState.hoveredElement, { + clientX: hoverState.pointerClientX, + clientY: hoverState.pointerClientY, + preferSelection: true, + }); + if (!precisionTarget || !precisionTarget.rect) { + hidePreview(); + return; + } + + var rect = precisionTarget.rect; + if (!hasRenderableRect(rect)) { + hidePreview(); + return; + } + + hoverState.lastRenderedTarget = precisionTarget; + + var state = getOverlayState(); + positionOverlayBox(state.preview, rect, 2); + state.preview.classList.add("visible"); + + // Scope level badge + var scopeLabels = ["", "Sentence", "Paragraph", "Container"]; + if (hoverState.scopeLevel > 0 && hoverState.scopeLevel < scopeLabels.length) { + state.scopeBadge.textContent = scopeLabels[hoverState.scopeLevel]; + state.scopeBadge.style.display = "block"; + var previewTop = parseFloat(state.preview.style.top) || 0; + var previewLeft = parseFloat(state.preview.style.left) || 0; + state.scopeBadge.style.top = (previewTop - 22) + "px"; + state.scopeBadge.style.left = previewLeft + "px"; + } else { + state.scopeBadge.style.display = "none"; + } + + // Large selection warning + var textLength = getText(precisionTarget).length; + if (textLength > 3000) { + state.warnBadge.textContent = "⚠ " + Math.round(textLength / 1000) + "k chars"; + state.warnBadge.style.display = "block"; + var pTop = parseFloat(state.preview.style.top) || 0; + var pLeft = parseFloat(state.preview.style.left) || 0; + var pWidth = parseFloat(state.preview.style.width) || 0; + state.warnBadge.style.top = (pTop - 22) + "px"; + state.warnBadge.style.left = (pLeft + pWidth - 80) + "px"; + } else { + state.warnBadge.style.display = "none"; + } + } + + function hidePreview() { + if (hoverState.previewAnimationFrame) { + window.cancelAnimationFrame(hoverState.previewAnimationFrame); + hoverState.previewAnimationFrame = 0; + } + + if (!overlayState) { + hoverState.lastRenderedTarget = null; + return; + } + + hoverState.lastRenderedTarget = null; + overlayState.preview.classList.remove("visible"); + overlayState.scopeBadge.style.display = "none"; + overlayState.warnBadge.style.display = "none"; + } + + function getText(target) { + var context = resolveExtractionContext(target); + if (!context) { + return ""; + } + + var raw; + switch (context.kind) { + case "selection": + raw = context.text; + break; + case "scope": + raw = context.text || collectVisibleText(context.node).trim(); + break; + case "table": + raw = extractTableAsTsv(context.table); + break; + case "code": + raw = extractCodeText(context.container); + break; + case "list": + raw = extractListAsText(context.container); + break; + case "image": + raw = getImageText(context.element); + break; + case "link": + raw = "[" + (collectVisibleText(context.anchor).trim() || context.anchor.href) + "](" + context.anchor.href + ")"; + break; + case "control": + raw = getPlainText(context.element).trim(); + break; + default: + raw = collectVisibleText(context.node).trim(); + } + + return sanitizeText(raw); + } + + function resolveExtractionContext(target) { + if (!target) { + return null; + } + + if (target.kind == "selection") { + return target; + } + + if (target.kind == "scope") { + return target; + } + + var node = target.node || target.element || getElementNode(target); + var element = getElementNode(node); + if (!element) { + return null; + } + + if (element.nodeName.toUpperCase() == "IMG") { + return { kind: "image", element: element }; + } + + var table = typeof element.closest == "function" ? element.closest("table") : null; + if (table) { + return { kind: "table", table: table }; + } + + var codeContainer = typeof element.closest == "function" ? element.closest("pre, code") : null; + if (codeContainer) { + return { kind: "code", container: codeContainer }; + } + + var listContainer = typeof element.closest == "function" ? element.closest("ul, ol") : null; + if (listContainer) { + return { kind: "list", container: listContainer }; + } + + var anchor = typeof element.closest == "function" ? element.closest("a[href]") : null; + if (anchor) { + return { kind: "link", anchor: anchor }; + } + + var tagName = element.nodeName.toUpperCase(); + if (tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "SELECT") { + return { kind: "control", element: element }; + } + + return { kind: "text", node: node }; + } + + function getExtractionNode(node) { + var element = getElementNode(node); + if (!element) { + return null; + } + + return element; + } + + function getElementNode(node) { + if (!node) { + return null; + } + + if (node.nodeType == Node.TEXT_NODE) { + return node.parentNode; + } + + return node.nodeType == Node.ELEMENT_NODE ? node : null; + } + + function getImageText(node) { + return node.getAttribute("src") || node.getAttribute("alt") || ""; + } + + function collectVisibleText(node) { + if (!node) { + return ""; + } + + if (node.nodeType == Node.TEXT_NODE) { + return isNodeVisible(node) ? String(node.textContent || "") : ""; + } + + var walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { + acceptNode: function (textNode) { + return isNodeVisible(textNode) && String(textNode.textContent || "").trim() + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + } + }); + + var parts = []; + var current = walker.nextNode(); + while (current) { + parts.push(String(current.textContent || "").trim()); + current = walker.nextNode(); + } + + return parts.join(" ").replace(/\s+/g, " ").trim(); + } + + function extractTableAsTsv(table) { + return Array.from(table.rows || []).map(function (row) { + return Array.from(row.cells || []).filter(isNodeVisible).map(function (cell) { + return collectVisibleText(cell).replace(/\s*\n+\s*/g, " ").trim(); + }).join("\t"); + }).filter(Boolean).join("\n"); + } + + function extractCodeText(container) { + var clone = container.cloneNode(true); + Array.from(clone.querySelectorAll("[aria-hidden='true'], .line-numbers, .line-number, .lineno, .gutter, .blob-num")).forEach(function (element) { + element.remove(); + }); + return collectVisibleText(clone).replace(/\u00a0/g, " ").trim(); + } + + function extractListAsText(container) { + var isOrdered = container.nodeName.toUpperCase() === "OL"; + var items = Array.from(container.children).filter(function (child) { + return child.nodeName.toUpperCase() === "LI" && isNodeVisible(child); + }); + + return items.map(function (li, index) { + var nestedList = li.querySelector("ul, ol"); + var mainText = ""; + + if (nestedList) { + var clone = li.cloneNode(true); + Array.from(clone.querySelectorAll("ul, ol")).forEach(function (nested) { + nested.remove(); + }); + mainText = collectVisibleText(clone).trim(); + } else { + mainText = collectVisibleText(li).trim(); + } + + var prefix = isOrdered ? (index + 1) + ". " : "- "; + var line = prefix + mainText; + + if (nestedList) { + var nestedLines = extractListAsText(nestedList).split("\n").map(function (nestedLine) { + return " " + nestedLine; + }).join("\n"); + line += "\n" + nestedLines; + } + + return line; + }).join("\n"); + } + + function sanitizeText(text) { + return String(text || "") + .replace(/\u200B/g, "") // zero-width spaces + .replace(/\u200C/g, "") // zero-width non-joiner + .replace(/\u200D/g, "") // zero-width joiner + .replace(/\uFEFF/g, "") // BOM + .replace(/\u00A0/g, " ") // non-breaking space → regular space + .replace(/[ \t]+/g, " ") // collapse horizontal whitespace + .replace(/(\r?\n){3,}/g, "\n\n") // max 2 consecutive newlines + .trim(); + } + + function getPlainText(node) { + if (!node) { + return ""; } switch (node.nodeName.toUpperCase()) { case "INPUT": case "TEXTAREA": - return node.value; + return node.value || ""; case "SELECT": - return Array.from(node.selectedOptions).map(o => o.innerText).join("\n") + return Array.from(node.selectedOptions).map(function (option) { + return option.innerText; + }).join("\n"); + case "IMG": + return getImageText(node); + default: + return collectVisibleText(node); } + } - return node.innerText; + async function copyCommand(clickedElement, source, options) { + var precisionTarget = resolvePrecisionTarget(clickedElement, options); + if (!precisionTarget) { + return; + } + await executePrecisionCopy(precisionTarget, source); } - function copyCommand(clickedElement) { - var text = getText(clickedElement); - copy(text); + async function executePrecisionCopy(precisionTarget, source) { + var text = getText(precisionTarget); + if (!text) { + return; + } - var rect = clickedElement.getBoundingClientRect(); - var frame = document.createElement("div"); - Object.assign(frame.style, { - position: "absolute", - margin: "initial", - padding: "initial", - background: "initial", - top: (rect.top + window.scrollY) + "px", - left: (rect.left + window.scrollX) + "px", - width: (rect.width - 4) + "px", - height: (rect.height - 4) + "px", - border: "solid 2px gold", - borderRadius: "5px", - zIndex: "99999", - pointerEvents: "none", - }); + // Extract HTML for rich-text copy support + var htmlContent = getHtmlContent(precisionTarget); + + var result = "copied"; + await copy(text, htmlContent); - document.body.appendChild(frame); - setTimeout(() => { - frame.addEventListener("transitionend", (event) => { - frame.remove(); + hoverState.scopeLevel = 0; + hoverState.scopeAnchorClientX = null; + hoverState.scopeAnchorClientY = null; + + showCopyFeedback(precisionTarget.rect, result); + var analyticsEvents = [{ + type: getAnalyticsTypeForResult(result, source || "click", precisionTarget.kind == "selection"), + hostname: window.location.hostname, + toastKind: getToastAnalyticsKind(result), + }]; + if ((source || "click") == "shortcut") { + analyticsEvents.push({ + type: "shortcut", + hostname: window.location.hostname, }); - frame.style.opacity = "0"; - frame.style.transition = "opacity ease-out 0.5s"; - }, 500); + } + saveAnalyticsEvents(analyticsEvents); + await saveHistory(text, result, source || "click", precisionTarget.kind == "selection"); } - async function copy(text) { - if (navigator.clipboard) { - await navigator.clipboard.writeText(text); - return; + function getHtmlContent(target) { + if (!target) { + return ""; } - // fallback (for http) - const textArea = document.createElement("textarea"); - textArea.style.cssText = "position: absolute; left: -100%;"; try { - document.body.appendChild(textArea); + if (target.range) { + var fragment = target.range.cloneContents(); + var wrapper = document.createElement("div"); + wrapper.appendChild(fragment); + return sanitizeHtml(serializeNodeChildrenToHtml(wrapper)); + } - textArea.value = text; - textArea.select() + var node = target.node; + var element = getElementNode(node); + if (element && element.outerHTML) { + return sanitizeHtml(element.outerHTML); + } + } catch (error) { + // Silently fall back to no HTML + } + return ""; + } + + function serializeNodeChildrenToHtml(node) { + return Array.from(node.childNodes).map(serializeNodeToHtml).join(""); + } + + function serializeNodeToHtml(node) { + if (node.nodeType == Node.TEXT_NODE) { + return escapeHtmlText(node.textContent || ""); + } + + if (node.nodeType == Node.ELEMENT_NODE && node.outerHTML) { + return node.outerHTML; + } + + return new XMLSerializer().serializeToString(node); + } + + function escapeHtmlText(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">"); + } + + function sanitizeHtml(html) { + if (!html) { + return ""; + } + + return html + .replace(//gi, "") + .replace(/\son\w+\s*=\s*["'][^"']*["']/gi, "") + .replace(/\son\w+\s*=\s*\S+/gi, "") + .replace(/javascript\s*:/gi, "") + .replace(/\sdata-track[\w-]*\s*=\s*["'][^"']*["']/gi, "") + .replace(/\sdata-analytics[\w-]*\s*=\s*["'][^"']*["']/gi, ""); + } + + async function copy(text, htmlContent) { + // Try rich-text copy with both text/plain and text/html via ClipboardItem API + if (htmlContent && navigator.clipboard && typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined") { + try { + var textBlob = new Blob([text], { type: "text/plain" }); + var htmlBlob = new Blob([htmlContent], { type: "text/html" }); + await navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": textBlob, + "text/html": htmlBlob + }) + ]); + return; + } catch (error) { + console.warn("ClipboardItem write failed, falling back to writeText.", error); + } + } + + // Fallback: plain-text only via writeText + if (navigator.clipboard && typeof navigator.clipboard.writeText == "function") { + try { + await navigator.clipboard.writeText(text); + return; + } catch (error) { + console.warn("Clipboard writeText failed, using fallback copy.", error); + } + } + + // Final fallback: execCommand + var container = document.body || document.documentElement; + var textArea = document.createElement("textarea"); + textArea.style.cssText = "position:absolute;left:-100%;top:0;"; + + try { + container.appendChild(textArea); + textArea.value = text; + textArea.select(); + suppressNativeCopyTracking = true; if (!document.execCommand("copy")) { console.error("Copy failed."); } } finally { - document.body.removeChild(textArea); + suppressNativeCopyTracking = false; + textArea.remove(); + } + } + + function showCopyFeedback(rect, result) { + var state = getOverlayState(); + + if (hasRenderableRect(rect)) { + positionOverlayBox(state.feedback, rect, 4); + state.feedback.style.animationDuration = Math.max(350, settings.toastDurationMs) + "ms"; + restartAnimation(state.feedback, "visible"); + } + + spawnCursorToast(getCopyToastText(result), getToastPageX(rect), getToastPageY(rect)); + } + + function getCopyToastText(result) { + return t("toast_copied", "Copied!"); + } + + function getToastPageX(rect) { + if (hoverState.pointerPageX !== null) { + return hoverState.pointerPageX; + } + + return rect.left + window.scrollX + (rect.width / 2); + } + + function getToastPageY(rect) { + if (hoverState.pointerPageY !== null) { + return hoverState.pointerPageY; + } + + return rect.top + window.scrollY + Math.min(24, rect.height / 2); + } + + function spawnCursorToast(label, pageX, pageY) { + var state = getOverlayState(); + var toast = document.createElement("div"); + toast.className = "cursor-toast"; + toast.textContent = label; + + // Viewport-aware positioning: clamp toast within visible bounds + var toastW = 80; // estimated width + var toastH = 28; // estimated height + var margin = 8; + var clientX = pageX - window.scrollX; + var clientY = pageY - window.scrollY; + + // Horizontal: prefer right of cursor, flip left if overflow + var adjustedClientX = clientX + 16; + if (adjustedClientX + toastW + margin > window.innerWidth) { + adjustedClientX = Math.max(margin, clientX - toastW - 16); + } + + // Vertical: prefer above cursor, flip below if overflow + var adjustedClientY = clientY - 18; + if (adjustedClientY - toastH < margin) { + adjustedClientY = Math.min(window.innerHeight - toastH - margin, clientY + 24); + } + + // Convert back to page coordinates + var finalPageX = adjustedClientX + window.scrollX; + var finalPageY = adjustedClientY + window.scrollY; + + toast.style.left = Math.round(finalPageX) + "px"; + toast.style.top = Math.round(finalPageY) + "px"; + toast.style.animationDuration = settings.toastDurationMs + "ms"; + state.layer.appendChild(toast); + toast.addEventListener("animationend", function () { + toast.remove(); + }, { once: true }); + } + + function showStatusToast(label) { + var state = getOverlayState(); + var toast = document.createElement("div"); + toast.className = "cursor-toast"; + toast.textContent = label; + toast.style.left = Math.round(window.scrollX + (window.innerWidth / 2)) + "px"; + toast.style.top = Math.round(window.scrollY + Math.min(window.innerHeight * 0.3, 180)) + "px"; + toast.style.transform = "translateX(-50%)"; + toast.style.animationDuration = settings.toastDurationMs + "ms"; + state.layer.appendChild(toast); + toast.addEventListener("animationend", function () { + toast.remove(); + }, { once: true }); + } + + async function saveHistory(text, result, source, isSelectionBased) { + if (!settings.copyHistoryLimit) { + return; } + + try { + var current = await chrome.storage.local.get({ copyHistory: [] }); + var nextHistory = utils.pushHistoryEntry(current.copyHistory, { + text: text, + snippet: utils.getTextSnippet(text), + source: source, + mode: "copy", + url: window.location.href, + hostname: window.location.hostname, + pinned: false, + replayCount: 0, + lastReplayedAt: isSelectionBased ? Date.now() : 0, + }, settings.copyHistoryLimit); + + await chrome.storage.local.set({ copyHistory: nextHistory }); + } catch (error) { + console.warn("Saving copy history failed.", error); + } + } + + async function saveAnalyticsEvent(event) { + return saveAnalyticsEvents([event]); + } + + async function saveAnalyticsEvents(events) { + try { + var current = await chrome.storage.local.get({ copyAnalytics: utils.DEFAULT_ANALYTICS }); + var nextAnalytics = current.copyAnalytics; + (Array.isArray(events) ? events : [events]).forEach(function (event) { + nextAnalytics = utils.recordAnalyticsEvent(nextAnalytics, event || {}); + }); + await chrome.storage.local.set({ copyAnalytics: nextAnalytics }); + } catch (error) { + console.warn("Saving copy analytics failed.", error); + } + } + + function getAnalyticsTypeForResult(result, source, isSelectionBased) { + if (source == "history") { + return "historyReplayCopy"; + } + + if (isSelectionBased) { + return "selectionCopy"; + } + + return "copy"; + } + + function getToastAnalyticsKind(result) { + return "copied"; + } + + function resolveShortcutTarget() { + if (hoverState.hoveredElement && hoverState.hoveredElement.isConnected) { + return hoverState.hoveredElement; + } + + if (document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement) { + return document.activeElement; + } + + var selection = window.getSelection ? window.getSelection() : null; + if (selection && selection.anchorNode) { + return selection.anchorNode; + } + + return null; + } + + function getNativeCopiedText() { + var activeElement = document.activeElement; + if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { + var start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; + var end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; + var value = String(activeElement.value || ""); + if (end > start) { + return value.slice(start, end).trim(); + } + } + + var selection = window.getSelection ? window.getSelection() : null; + return selection ? String(selection.toString() || "").trim() : ""; + } + + function positionOverlayBox(element, rect, expansion) { + var pageTop = rect.top + window.scrollY - expansion; + var pageLeft = rect.left + window.scrollX - expansion; + var width = rect.width + (expansion * 2); + var height = rect.height + (expansion * 2); + + element.style.top = Math.round(pageTop) + "px"; + element.style.left = Math.round(pageLeft) + "px"; + element.style.width = Math.max(1, Math.round(width)) + "px"; + element.style.height = Math.max(1, Math.round(height)) + "px"; + } + + function hasRenderableRect(rect) { + return !!rect && rect.width > 0 && rect.height > 0; + } + + function restartAnimation(element, className) { + element.classList.remove(className); + void element.offsetWidth; + element.classList.add(className); + } + + function getOverlayState() { + if (overlayState && overlayState.host.isConnected) { + return overlayState; + } + + var host = document.createElement("div"); + host.setAttribute("data-copy-text-overlay-root", ""); + Object.assign(host.style, { + all: "initial", + position: "absolute", + top: "0", + left: "0", + width: "0", + height: "0", + zIndex: "2147483647", + pointerEvents: "none", + }); + + var shadowRoot = host.attachShadow({ mode: "closed" }); + var style = document.createElement("style"); + style.textContent = [ + ":host { all: initial; }", + ".layer {", + " position: relative;", + " pointer-events: none;", + "}", + ".preview,", + ".feedback,", + ".cursor-toast,", + ".scope-badge,", + ".warn-badge {", + " position: absolute;", + " pointer-events: none;", + " box-sizing: border-box;", + "}", + /* ── Highlighter-style preview ── */ + ".preview {", + " z-index: 99998;", + " opacity: 0;", + " border: 2px solid rgba(250, 204, 21, 0.7);", + " border-radius: 6px;", + " background: rgba(250, 204, 21, 0.18);", + " mix-blend-mode: multiply;", + " box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.08), inset 0 0 12px rgba(250, 204, 21, 0.12);", + " transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1), top 100ms ease, left 100ms ease, width 100ms ease, height 100ms ease;", + "}", + ".preview.visible {", + " opacity: 1;", + "}", + /* Dark mode: switch to additive blend for white text */ + "@media (prefers-color-scheme: dark) {", + " .preview {", + " border-color: rgba(56, 189, 248, 0.6);", + " background: rgba(56, 189, 248, 0.12);", + " mix-blend-mode: screen;", + " box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.1), inset 0 0 12px rgba(56, 189, 248, 0.08);", + " }", + "}", + /* ── Scope badge ── */ + ".scope-badge {", + " z-index: 100001;", + " display: none;", + " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", + " font-size: 10px;", + " font-weight: 700;", + " line-height: 1;", + " letter-spacing: 0.04em;", + " text-transform: uppercase;", + " color: #92400e;", + " background: rgba(253, 230, 138, 0.92);", + " border: 1px solid rgba(250, 204, 21, 0.4);", + " padding: 3px 7px;", + " border-radius: 6px;", + " white-space: nowrap;", + "}", + "@media (prefers-color-scheme: dark) {", + " .scope-badge {", + " color: #bae6fd;", + " background: rgba(7, 89, 133, 0.88);", + " border-color: rgba(56, 189, 248, 0.35);", + " }", + "}", + /* ── Large selection warning ── */ + ".warn-badge {", + " z-index: 100001;", + " display: none;", + " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", + " font-size: 10px;", + " font-weight: 700;", + " line-height: 1;", + " color: #b91c1c;", + " background: rgba(254, 226, 226, 0.94);", + " border: 1px solid rgba(248, 113, 113, 0.4);", + " padding: 3px 7px;", + " border-radius: 6px;", + " white-space: nowrap;", + "}", + "@media (prefers-color-scheme: dark) {", + " .warn-badge {", + " color: #fca5a5;", + " background: rgba(127, 29, 29, 0.88);", + " border-color: rgba(248, 113, 113, 0.35);", + " }", + "}", + /* ── Copy feedback pulse ── */ + ".feedback {", + " z-index: 99999;", + " opacity: 0;", + " border: 1px solid rgba(110, 231, 183, 0.96);", + " border-radius: 8px;", + " background: linear-gradient(135deg, rgba(45, 212, 191, 0.22), rgba(34, 197, 94, 0.18));", + " box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.14), 0 0 28px rgba(45, 212, 191, 0.35);", + "}", + ".feedback.visible {", + " animation: feedbackPulse 1150ms cubic-bezier(0.16, 1, 0.3, 1) forwards;", + "}", + "@keyframes feedbackPulse {", + " 0% { opacity: 0.96; transform: scale(0.985); }", + " 58% { opacity: 0.7; transform: scale(1); }", + " 100% { opacity: 0; transform: scale(1.02); }", + "}", + /* ── Cursor toast ── */ + ".cursor-toast {", + " z-index: 100000;", + " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", + " font-size: 12px;", + " font-weight: 650;", + " line-height: 1;", + " letter-spacing: 0.01em;", + " color: #ecfeff;", + " white-space: nowrap;", + " padding: 7px 10px;", + " border-radius: 999px;", + " border: 1px solid rgba(125, 211, 252, 0.32);", + " background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(8, 47, 73, 0.92));", + " box-shadow: 0 10px 24px rgba(2, 6, 23, 0.28), 0 0 18px rgba(45, 212, 191, 0.24);", + " animation: cursorToastFloat 1250ms cubic-bezier(0.22, 1, 0.36, 1) forwards;", + "}", + "@keyframes cursorToastFloat {", + " 0% { opacity: 0; transform: translateY(4px); }", + " 12% { opacity: 1; transform: translateY(0); }", + " 100% { opacity: 0; transform: translateY(-28px); }", + "}", + "@media (prefers-reduced-motion: reduce) {", + " .preview { transition: none; }", + " .feedback.visible { animation-duration: 350ms; }", + " .cursor-toast { animation-duration: 700ms; }", + "}" + ].join("\n"); + + var layer = document.createElement("div"); + layer.className = "layer"; + + var preview = document.createElement("div"); + preview.className = "preview"; + + var feedback = document.createElement("div"); + feedback.className = "feedback"; + + var scopeBadge = document.createElement("div"); + scopeBadge.className = "scope-badge"; + + var warnBadge = document.createElement("div"); + warnBadge.className = "warn-badge"; + + layer.appendChild(preview); + layer.appendChild(feedback); + layer.appendChild(scopeBadge); + layer.appendChild(warnBadge); + shadowRoot.appendChild(style); + shadowRoot.appendChild(layer); + + (document.documentElement || document.body).appendChild(host); + + overlayState = { + host: host, + layer: layer, + preview: preview, + feedback: feedback, + scopeBadge: scopeBadge, + warnBadge: warnBadge, + }; + + return overlayState; } })(); diff --git a/options.css b/options.css new file mode 100644 index 0000000..d61f059 --- /dev/null +++ b/options.css @@ -0,0 +1,678 @@ +:root { + color-scheme: light dark; + font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + + /* ── Light theme tokens ── */ + --bg: #eef6ff; + --bg-gradient-a: rgba(14, 165, 233, 0.15); + --bg-gradient-b: rgba(16, 185, 129, 0.1); + --surface: rgba(255, 255, 255, 0.88); + --surface-strong: rgba(255, 255, 255, 0.96); + --surface-raised: rgba(248, 250, 252, 0.86); + --border: rgba(148, 163, 184, 0.28); + --border-strong: rgba(148, 163, 184, 0.5); + --border-item: rgba(226, 232, 240, 0.95); + --text: #0f172a; + --text-secondary: #334155; + --muted: #64748b; + --accent: #0f766e; + --accent-strong: #0369a1; + --accent-surface: rgba(15, 118, 110, 0.08); + --accent-border: rgba(15, 118, 110, 0.22); + --focus-ring: rgba(14, 165, 233, 0.5); + --shadow: 0 18px 40px rgba(15, 23, 42, 0.08); + --shadow-button: 0 12px 26px rgba(8, 47, 73, 0.15); + --tab-active-bg: linear-gradient(135deg, rgba(15, 118, 110, 0.14), rgba(3, 105, 161, 0.12)); + --tab-active-border: rgba(14, 165, 233, 0.32); + --tab-hover-bg: rgba(255, 255, 255, 0.7); + --selected-border: rgba(14, 165, 233, 0.45); + --selected-shadow: 0 0 0 2px rgba(14, 165, 233, 0.18); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0c1222; + --bg-gradient-a: rgba(14, 165, 233, 0.08); + --bg-gradient-b: rgba(16, 185, 129, 0.06); + --surface: rgba(30, 41, 59, 0.82); + --surface-strong: rgba(30, 41, 59, 0.96); + --surface-raised: rgba(30, 41, 59, 0.72); + --border: rgba(100, 116, 139, 0.22); + --border-strong: rgba(100, 116, 139, 0.38); + --border-item: rgba(51, 65, 85, 0.7); + --text: #e2e8f0; + --text-secondary: #cbd5e1; + --muted: #94a3b8; + --accent: #2dd4bf; + --accent-strong: #38bdf8; + --accent-surface: rgba(45, 212, 191, 0.1); + --accent-border: rgba(45, 212, 191, 0.22); + --focus-ring: rgba(56, 189, 248, 0.5); + --shadow: 0 18px 40px rgba(0, 0, 0, 0.35); + --shadow-button: 0 12px 26px rgba(0, 0, 0, 0.35); + --tab-active-bg: linear-gradient(135deg, rgba(45, 212, 191, 0.14), rgba(56, 189, 248, 0.12)); + --tab-active-border: rgba(56, 189, 248, 0.32); + --tab-hover-bg: rgba(51, 65, 85, 0.5); + --selected-border: rgba(56, 189, 248, 0.45); + --selected-shadow: 0 0 0 2px rgba(56, 189, 248, 0.18); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top right, var(--bg-gradient-a), transparent 38%), + radial-gradient(circle at bottom left, var(--bg-gradient-b), transparent 32%), + var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Layout ── */ +.settings-shell { + max-width: 1200px; + margin: 0 auto; + padding: 24px; + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 24px; +} + +.settings-sidebar, +.settings-main { + display: grid; + gap: 18px; + align-content: start; +} + +/* ── Cards ── */ +.hero-card, +.panel-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + box-shadow: var(--shadow); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + transition: border-color 200ms ease; +} + +.hero-card { + padding: 20px; + display: grid; + gap: 8px; +} + +.eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} + +h1 { + margin: 0; + font-size: 28px; + line-height: 1.15; + letter-spacing: -0.01em; +} + +.hero-copy { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: var(--muted); +} + +/* ── Tabs ── */ +.tab-list { + display: grid; + gap: 6px; +} + +.tab-button { + font: inherit; + cursor: pointer; + width: 100%; + text-align: left; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid transparent; + background: transparent; + color: var(--text); + font-weight: 500; + transition: background 200ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 200ms ease, + transform 200ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 200ms ease; +} + +.tab-button:hover, +.tab-button:focus-visible { + background: var(--tab-hover-bg); + border-color: var(--border); + outline: none; +} + +.tab-button:active { + transform: scale(0.98); +} + +.tab-button.active { + background: var(--tab-active-bg); + border-color: var(--tab-active-border); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); + font-weight: 650; +} + +.tab-panel { + display: none; + animation: panelFadeIn 280ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.tab-panel.active { + display: block; +} + +@keyframes panelFadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Panel cards ── */ +.panel-card { + padding: 20px; + display: grid; + gap: 18px; +} + +.panel-title { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.005em; +} + +.panel-subtitle { + font-size: 14px; + font-weight: 700; +} + +/* ── Forms ── */ +.field-group { + display: grid; + gap: 8px; +} + +label, +.toggle-title { + font-size: 14px; + font-weight: 650; + color: var(--text-secondary); +} + +input, +select, +textarea, +button { + font: inherit; +} + +input, +select, +textarea { + width: 100%; + padding: 11px 12px; + border-radius: 12px; + border: 1px solid var(--border-strong); + background: var(--surface-strong); + color: var(--text); + transition: border-color 200ms ease, box-shadow 200ms ease; +} + +input:hover, +select:hover, +textarea:hover { + border-color: var(--accent); +} + +textarea { + min-height: 140px; + resize: vertical; +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +button:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +/* ── Toggle cards ── */ +.toggle-card { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: start; + padding: 14px; + border-radius: 16px; + border: 1px solid var(--border-item); + background: var(--surface-raised); + cursor: pointer; + transition: border-color 200ms ease, + background 200ms ease, + transform 200ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.toggle-card:hover { + border-color: var(--accent-border); + background: var(--accent-surface); +} + +.toggle-card:active { + transform: scale(0.995); +} + +.compact-toggle { + grid-template-columns: auto 1fr; + padding: 10px 12px; +} + +.toggle-card input { + width: 18px; + height: 18px; + margin-top: 2px; + accent-color: var(--accent); +} + +/* ── Help text ── */ +.field-help, +.inline-note, +.empty-state, +.history-meta { + font-size: 13px; + line-height: 1.55; + color: var(--muted); +} + +/* ── Inline layouts ── */ +.inline-field, +.inline-actions, +.duration-inline, +.history-actions { + display: flex; + gap: 10px; + align-items: center; +} + +/* ── Buttons ── */ +.primary-button, +.secondary-button { + cursor: pointer; + border-radius: 12px; + padding: 11px 14px; + border: 0; + font-weight: 600; + transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 200ms ease, + opacity 200ms ease, + background 200ms ease; +} + +.primary-button:hover, +.secondary-button:hover { + transform: translateY(-1px); +} + +.primary-button:active, +.secondary-button:active { + transform: translateY(0) scale(0.98); +} + +.primary-button { + color: #ecfeff; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + box-shadow: var(--shadow-button); +} + +.primary-button:hover { + box-shadow: var(--shadow-button), 0 0 0 1px var(--accent); +} + +.secondary-button { + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border-strong); +} + +.secondary-button:hover { + border-color: var(--accent-border); + background: var(--accent-surface); +} + +/* ── Duration ── */ +.duration-grid { + display: grid; + gap: 12px; +} + +.duration-inline input { + max-width: 150px; +} + +input[type="range"] { + accent-color: var(--accent); +} + +.suffix { + font-size: 13px; + color: var(--muted); +} + +/* ── Domain & history lists ── */ +.domain-list, +.history-list { + display: grid; + gap: 10px; +} + +.history-filters, +.analytics-shell, +.top-domains { + display: grid; + gap: 14px; +} + +.analytics-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.analytics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.analytics-card { + padding: 14px; + border-radius: 16px; + border: 1px solid var(--border-item); + background: var(--surface-raised); + display: grid; + gap: 6px; + transition: border-color 200ms ease, transform 200ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.analytics-card:hover { + border-color: var(--accent-border); + transform: translateY(-1px); +} + +.analytics-label { + font-size: 12px; + line-height: 1.4; + color: var(--muted); +} + +.analytics-value { + font-size: 28px; + font-weight: 750; + line-height: 1; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +/* ── List items ── */ +.domain-item, +.history-item { + display: grid; + gap: 12px; + padding: 14px; + border-radius: 14px; + border: 1px solid var(--border-item); + background: var(--surface-raised); + transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.domain-item:hover, +.history-item:hover { + border-color: var(--accent-border); + transform: translateY(-1px); +} + +.history-item.selected { + border-color: var(--selected-border); + box-shadow: var(--selected-shadow); +} + +.domain-row, +.history-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; +} + +.history-group { + display: grid; + gap: 10px; +} + +.history-group-label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +.domain-name { + font-weight: 700; + word-break: break-word; +} + +.history-snippet { + font-size: 14px; + line-height: 1.65; + color: var(--text); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.history-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.history-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1; + background: var(--surface-strong); + border: 1px solid var(--border-item); + color: var(--muted); +} + +.history-chip.source-extension, +.history-chip.source-shortcut, +.history-chip.source-history { + color: var(--accent); + border-color: var(--accent-border); + background: var(--accent-surface); +} + +.history-chip.source-native { + color: var(--muted); + border-color: var(--border-strong); + background: var(--surface-raised); +} + +.history-fulltext { + font-size: 12px; + line-height: 1.6; + margin: 0; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; + border-radius: 12px; + background: var(--surface-strong); + border: 1px solid var(--border-item); +} + +.save-status { + min-height: 20px; + font-size: 13px; + font-weight: 600; + color: var(--accent); + transition: opacity 300ms ease; +} + +/* ── Empty state styling ── */ +.empty-state { + text-align: center; + padding: 32px 20px; + color: var(--muted); +} + +.empty-state-icon { + font-size: 36px; + margin-bottom: 8px; + opacity: 0.5; +} + +/* ── Scrollbar styling ── */ +.history-list, +.domain-list { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +.history-list::-webkit-scrollbar, +.domain-list::-webkit-scrollbar { + width: 6px; +} + +.history-list::-webkit-scrollbar-track, +.domain-list::-webkit-scrollbar-track { + background: transparent; +} + +.history-list::-webkit-scrollbar-thumb, +.domain-list::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 999px; +} + +.history-list::-webkit-scrollbar-thumb:hover, +.domain-list::-webkit-scrollbar-thumb:hover { + background: var(--accent); +} + +/* ── Disabled button states ── */ +.primary-button:disabled, +.secondary-button:disabled { + cursor: not-allowed; + opacity: 0.45; + transform: none; + box-shadow: none; + pointer-events: none; +} + +/* ── Toggle card inner layout ── */ +.toggle-card > span { + display: grid; + gap: 4px; +} + +/* ── Fulltext monospace ── */ +.history-fulltext { + font-family: "Cascadia Code", "Fira Code", "SF Mono", "Consolas", monospace; +} + +/* ── Select dropdown dark mode ── */ +select { + appearance: auto; + -webkit-appearance: auto; +} + +@media (prefers-color-scheme: dark) { + select option { + background: #1e293b; + color: #e2e8f0; + } +} + +/* ── Responsive ── */ +@media (max-width: 900px) { + .settings-shell { + grid-template-columns: 1fr; + } + + .tab-list { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + } +} + +@media (max-width: 640px) { + .settings-shell { + padding: 16px; + } + + .inline-field, + .inline-actions, + .duration-inline, + .history-actions, + .domain-row, + .history-top { + flex-direction: column; + align-items: stretch; + } + + .duration-inline input { + max-width: none; + } + + .analytics-header { + flex-direction: column; + align-items: stretch; + } +} + +/* ── Reduced motion ── */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/options.html b/options.html index edfa270..3585ab2 100644 --- a/options.html +++ b/options.html @@ -1,18 +1,242 @@ - + Copy text with Alt-Click + + -
- - -
- +
+ + +
+
+
+
General controls
+ +
+ + +
Giữ phím copy rồi click để copy nhanh nội dung trên trang.
+
+ + + + + + + +
You can customize the extension shortcut in chrome://extensions/shortcuts.
+
+
+ +
+
+
Excluded domains
+
+ +
+ + +
+
+ +
+ + +
Paste one domain per line and click Apply.
+
+ +
+
+ +
Excluded domains disable both hover previews and copy actions.
+
+
+
+ +
+
+
Feedback behavior
+
+ +
+ +
+ + ms +
+
+
Custom duration for the floating copy feedback.
+
+
+
+ +
+
+
Language
+
+ + +
Auto follows the browser locale. English and Vietnamese are available as manual overrides.
+
+
+
+ +
+
+
Copy history
+ +
+
+
Local analytics
+ +
+
+
+
Total actions
+
0
+
+
+
Selection-first copies
+
0
+
+
+
Native copies
+
0
+
+
+
Shortcut usage
+
0
+
+
+
Blocked attempts
+
0
+
+
+
Toast events
+
0
+
+
+
+
Top domains
+
+
+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
How many recent copied entries should be kept locally.
+
+ +
+ + + + + +
+ +
+
+
+ +
+
+
+ + + diff --git a/options.js b/options.js index 262163a..2fff111 100644 --- a/options.js +++ b/options.js @@ -1,17 +1,795 @@ -function saveOptions() { - chrome.storage.sync.set({ - metaKey: document.getElementById("meta_key").value, - }); -} - -function restoreOptions() { - chrome.storage.sync.get({ - metaKey: 'Alt', - }, function (items) { - document.getElementById("meta_key").value = items.metaKey; - }); -} - -document.addEventListener('DOMContentLoaded', restoreOptions); -document.getElementById("meta_key").addEventListener("change", saveOptions); -document.getElementById("meta_key_label").textContent = chrome.i18n.getMessage("meta_key"); +(function () { + var utils = globalThis.CopyTextUtils; + var settings = utils ? utils.mergeSettings() : null; + var historyFilters = { + search: "", + source: "all", + mode: "all", + hostname: "all", + sort: "newest", + group: "none", + }; + var bulkSelectionMode = false; + var selectedHistoryIds = new Set(); + var statusTimerId; + + if (!utils) { + console.error("CopyTextUtils is not available."); + return; + } + + document.addEventListener("DOMContentLoaded", initializeOptions); + + async function initializeOptions() { + await restoreOptions(); + applyMessages(); + bindEvents(); + await renderExcludedDomains(); + await renderHistoryAndAnalytics(); + } + + function bindEvents() { + document.querySelectorAll(".tab-button").forEach(function (button) { + button.addEventListener("click", function () { + activateTab(button.dataset.tab); + }); + button.addEventListener("keydown", function (event) { + var tabs = Array.from(document.querySelectorAll(".tab-button")); + var index = tabs.indexOf(button); + var next = -1; + if (event.key === "ArrowDown" || event.key === "ArrowRight") { + next = (index + 1) % tabs.length; + } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") { + next = (index - 1 + tabs.length) % tabs.length; + } else if (event.key === "Home") { + next = 0; + } else if (event.key === "End") { + next = tabs.length - 1; + } + if (next >= 0) { + event.preventDefault(); + tabs[next].focus(); + activateTab(tabs[next].dataset.tab); + } + }); + }); + + document.getElementById("meta_key").addEventListener("change", saveOptions); + document.getElementById("preview_enabled").addEventListener("change", saveOptions); + document.getElementById("avoid_editable").addEventListener("change", saveOptions); + document.getElementById("keyboard_shortcut_enabled").addEventListener("change", saveOptions); + document.getElementById("ui_language").addEventListener("change", handleLanguageChange); + document.getElementById("copy_history_limit").addEventListener("change", saveOptions); + + document.getElementById("toast_duration").addEventListener("input", syncDurationControls); + document.getElementById("toast_duration_range").addEventListener("input", syncDurationControls); + document.getElementById("toast_duration").addEventListener("change", saveOptions); + document.getElementById("toast_duration_range").addEventListener("change", saveOptions); + + document.getElementById("add_domain_button").addEventListener("click", addDomainFromInput); + document.getElementById("domain_input").addEventListener("keydown", function (event) { + if (event.key == "Enter") { + event.preventDefault(); + addDomainFromInput(); + } + }); + document.getElementById("apply_bulk_button").addEventListener("click", applyBulkDomains); + + document.getElementById("history_search").addEventListener("input", function (event) { + historyFilters.search = event.target.value; + renderHistoryAndAnalytics(); + }); + document.getElementById("history_source_filter").addEventListener("change", function (event) { + historyFilters.source = event.target.value; + renderHistoryAndAnalytics(); + }); + document.getElementById("history_mode_filter").addEventListener("change", function (event) { + historyFilters.mode = event.target.value; + renderHistoryAndAnalytics(); + }); + document.getElementById("history_domain_filter").addEventListener("change", function (event) { + historyFilters.hostname = event.target.value; + renderHistoryAndAnalytics(); + }); + document.getElementById("history_sort").addEventListener("change", function (event) { + historyFilters.sort = event.target.value; + renderHistoryAndAnalytics(); + }); + document.getElementById("history_group").addEventListener("change", function (event) { + historyFilters.group = event.target.value; + renderHistoryAndAnalytics(); + }); + + document.getElementById("history_bulk_toggle_button").addEventListener("click", enableBulkSelection); + document.getElementById("history_bulk_cancel_button").addEventListener("click", disableBulkSelection); + document.getElementById("history_bulk_delete_button").addEventListener("click", bulkDeleteHistory); + document.getElementById("history_bulk_copy_button").addEventListener("click", function () { + bulkReplayHistory(); + }); + document.getElementById("clear_history_button").addEventListener("click", clearHistory); + document.getElementById("reset_analytics_button").addEventListener("click", resetAnalytics); + + chrome.storage.onChanged.addListener(function (changes, areaName) { + if (areaName == "sync") { + restoreOptions().then(function () { + applyMessages(); + renderExcludedDomains(); + renderHistoryAndAnalytics(); + }); + } + + if (areaName == "local" && (changes.copyHistory || changes.copyAnalytics)) { + renderHistoryAndAnalytics(); + } + }); + } + + async function handleLanguageChange() { + await saveOptions(); + applyMessages(); + await renderExcludedDomains(); + await renderHistoryAndAnalytics(); + } + + async function restoreOptions() { + settings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); + + document.getElementById("meta_key").value = settings.metaKey; + document.getElementById("preview_enabled").checked = settings.previewEnabled; + document.getElementById("avoid_editable").checked = settings.avoidEditable; + document.getElementById("keyboard_shortcut_enabled").checked = settings.keyboardShortcutEnabled; + document.getElementById("ui_language").value = settings.uiLanguage; + document.getElementById("copy_history_limit").value = String(settings.copyHistoryLimit); + document.getElementById("toast_duration").value = String(settings.toastDurationMs); + document.getElementById("toast_duration_range").value = String(settings.toastDurationMs); + document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); + document.getElementById("history_sort").value = historyFilters.sort; + document.getElementById("history_group").value = historyFilters.group; + } + + async function saveOptions() { + settings = utils.mergeSettings({ + metaKey: document.getElementById("meta_key").value, + previewEnabled: document.getElementById("preview_enabled").checked, + avoidEditable: document.getElementById("avoid_editable").checked, + keyboardShortcutEnabled: document.getElementById("keyboard_shortcut_enabled").checked, + toastDurationMs: document.getElementById("toast_duration").value, + uiLanguage: document.getElementById("ui_language").value, + copyHistoryLimit: document.getElementById("copy_history_limit").value, + excludedDomains: document.getElementById("excluded_domains_bulk").value, + }); + + await chrome.storage.sync.set(settings); + showStatus(t("save_status_saved", "Saved")); + await renderExcludedDomains(); + } + + function syncDurationControls(event) { + var value = utils.normalizeToastDuration(event.target.value); + document.getElementById("toast_duration").value = String(value); + document.getElementById("toast_duration_range").value = String(value); + } + + async function addDomainFromInput() { + var input = document.getElementById("domain_input"); + var normalized = utils.normalizeDomain(input.value); + if (!normalized) { + return; + } + + settings.excludedDomains = utils.toggleDomain(settings.excludedDomains, normalized); + document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); + input.value = ""; + + await chrome.storage.sync.set(settings); + await renderExcludedDomains(); + showStatus(t("save_status_saved", "Saved")); + } + + async function applyBulkDomains() { + settings.excludedDomains = utils.normalizeExcludedDomains(document.getElementById("excluded_domains_bulk").value); + document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); + + await chrome.storage.sync.set(settings); + await renderExcludedDomains(); + showStatus(t("save_status_saved", "Saved")); + } + + async function renderExcludedDomains() { + var list = document.getElementById("excluded_domains_list"); + list.textContent = ""; + + if (!settings.excludedDomains.length) { + var empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = t("excluded_domains_empty", "No excluded domains yet."); + list.appendChild(empty); + return; + } + + settings.excludedDomains.forEach(function (domain) { + var item = document.createElement("div"); + item.className = "domain-item"; + + var row = document.createElement("div"); + row.className = "domain-row"; + + var name = document.createElement("div"); + name.className = "domain-name"; + name.textContent = domain; + + var remove = document.createElement("button"); + remove.className = "secondary-button"; + remove.type = "button"; + remove.textContent = t("domain_remove_button", "Remove"); + remove.addEventListener("click", async function () { + settings.excludedDomains = utils.toggleDomain(settings.excludedDomains, domain); + document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); + await chrome.storage.sync.set(settings); + await renderExcludedDomains(); + showStatus(t("save_status_saved", "Saved")); + }); + + row.appendChild(name); + row.appendChild(remove); + item.appendChild(row); + list.appendChild(item); + }); + } + + async function renderHistoryAndAnalytics() { + var current = await chrome.storage.local.get({ + copyHistory: [], + copyAnalytics: utils.DEFAULT_ANALYTICS, + }); + + var history = Array.isArray(current.copyHistory) ? current.copyHistory : []; + var analytics = utils.normalizeAnalytics(current.copyAnalytics); + + populateHistoryDomainFilter(history); + renderAnalytics(analytics); + renderHistory(history); + } + + function populateHistoryDomainFilter(history) { + var filter = document.getElementById("history_domain_filter"); + var currentValue = historyFilters.hostname; + var hosts = utils.getHistoryHostOptions(history); + + filter.textContent = ""; + + var allOption = document.createElement("option"); + allOption.value = "all"; + allOption.textContent = t("history_filter_all", "All"); + filter.appendChild(allOption); + + hosts.forEach(function (hostname) { + var option = document.createElement("option"); + option.value = hostname; + option.textContent = hostname; + filter.appendChild(option); + }); + + filter.value = hosts.includes(currentValue) ? currentValue : "all"; + historyFilters.hostname = filter.value; + } + + function renderAnalytics(analytics) { + document.getElementById("analytics_total_actions_value").textContent = String(analytics.totals.totalActions || 0); + document.getElementById("analytics_append_actions_value").textContent = String(analytics.totals.selectionCopies || 0); + document.getElementById("analytics_native_actions_value").textContent = String(analytics.totals.nativeCopies || 0); + document.getElementById("analytics_shortcut_actions_value").textContent = String(analytics.totals.shortcuts || 0); + document.getElementById("analytics_blocked_actions_value").textContent = String((analytics.totals.excludedBlocked || 0) + (analytics.totals.editableSkipped || 0)); + document.getElementById("analytics_toast_events_value").textContent = String( + (analytics.toastCounts.copied || 0) + + (analytics.toastCounts.status || 0) + ); + + var list = document.getElementById("analytics_top_domains_list"); + list.textContent = ""; + + var topDomains = utils.getTopDomainStats(analytics, 5); + if (!topDomains.length) { + var empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = t("analytics_empty_domains", "No domain activity yet."); + list.appendChild(empty); + return; + } + + topDomains.forEach(function (item) { + var card = document.createElement("div"); + card.className = "domain-item"; + + var row = document.createElement("div"); + row.className = "domain-row"; + + var name = document.createElement("div"); + name.className = "domain-name"; + name.textContent = item.hostname; + + var meta = document.createElement("div"); + meta.className = "history-meta"; + meta.appendChild(createHistoryChip(t("analytics_total_actions", "Total actions") + ": " + item.totalActions, "domain-metric")); + meta.appendChild(createHistoryChip(t("analytics_shortcut_actions", "Shortcut usage") + ": " + item.shortcuts, "domain-metric")); + + row.appendChild(name); + card.appendChild(row); + card.appendChild(meta); + list.appendChild(card); + }); + } + + function renderHistory(history) { + var filteredHistory = utils.filterHistoryEntries(history, historyFilters); + var sortedHistory = utils.sortHistoryEntries(filteredHistory, historyFilters.sort); + var groupedHistory = utils.groupHistoryEntries(sortedHistory, historyFilters.group); + var list = document.getElementById("options_history_list"); + list.textContent = ""; + + if (!sortedHistory.length) { + var empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = history.length + ? t("history_no_results", "No matching history entries.") + : t("copy_history_empty", "No copied items yet."); + list.appendChild(empty); + updateBulkToolbar(); + return; + } + + groupedHistory.forEach(function (group) { + if (historyFilters.group != "none") { + var groupWrap = document.createElement("div"); + groupWrap.className = "history-group"; + + var groupLabel = document.createElement("div"); + groupLabel.className = "history-group-label"; + groupLabel.textContent = getGroupLabel(group); + groupWrap.appendChild(groupLabel); + + group.entries.forEach(function (item) { + groupWrap.appendChild(createHistoryItem(item)); + }); + list.appendChild(groupWrap); + return; + } + + group.entries.forEach(function (item) { + list.appendChild(createHistoryItem(item)); + }); + }); + + updateBulkToolbar(); + } + + function createHistoryItem(item) { + var wrapper = document.createElement("div"); + wrapper.className = "history-item"; + wrapper.classList.toggle("selected", selectedHistoryIds.has(item.id)); + + var top = document.createElement("div"); + top.className = "history-top"; + + var content = document.createElement("div"); + content.className = "field-group"; + + if (bulkSelectionMode) { + var selector = document.createElement("input"); + selector.type = "checkbox"; + selector.checked = selectedHistoryIds.has(item.id); + selector.addEventListener("change", function () { + if (selector.checked) { + selectedHistoryIds.add(item.id); + } else { + selectedHistoryIds.delete(item.id); + } + updateBulkToolbar(); + wrapper.classList.toggle("selected", selectedHistoryIds.has(item.id)); + }); + content.appendChild(selector); + } + + var snippet = document.createElement("div"); + snippet.className = "history-snippet"; + snippet.textContent = item.snippet || item.text; + + var meta = createHistoryMeta(item); + + content.appendChild(snippet); + content.appendChild(meta); + + var actions = document.createElement("div"); + actions.className = "history-actions"; + + var copyButton = document.createElement("button"); + copyButton.className = "secondary-button"; + copyButton.type = "button"; + copyButton.textContent = t("history_copy_button", "Copy again"); + copyButton.addEventListener("click", function () { + replayHistory(item); + }); + + var deleteButton = document.createElement("button"); + deleteButton.className = "secondary-button"; + deleteButton.type = "button"; + deleteButton.textContent = t("history_delete_button", "Delete"); + deleteButton.addEventListener("click", function () { + deleteHistoryItem(item.id); + }); + + var fullText = document.createElement("pre"); + fullText.className = "history-fulltext"; + fullText.hidden = true; + fullText.textContent = item.text; + + var expandButton = document.createElement("button"); + expandButton.className = "secondary-button"; + expandButton.type = "button"; + expandButton.textContent = t("history_expand_button", "View full text"); + expandButton.addEventListener("click", function () { + var isHidden = fullText.hidden; + fullText.hidden = !isHidden; + expandButton.textContent = isHidden + ? t("history_collapse_button", "Hide full text") + : t("history_expand_button", "View full text"); + }); + + actions.appendChild(copyButton); + actions.appendChild(expandButton); + actions.appendChild(deleteButton); + top.appendChild(content); + wrapper.appendChild(top); + wrapper.appendChild(actions); + wrapper.appendChild(fullText); + + return wrapper; + } + + function createHistoryMeta(item) { + var sourceMap = { + click: t("copy_history_source_click", "Extension click"), + shortcut: t("copy_history_source_shortcut", "Extension shortcut"), + history: t("copy_history_source_history", "History replay"), + native: t("copy_history_source_native", "Native copy"), + }; + var modeMap = { + copy: t("history_filter_copy", "Copy"), + }; + var meta = document.createElement("div"); + meta.className = "history-meta"; + meta.appendChild(createHistoryChip(sourceMap[item.source] || item.source, "source-" + normalizeSourceClass(item.source))); + meta.appendChild(createHistoryChip(modeMap[item.mode] || item.mode, "mode-chip")); + + var timeChip = createHistoryChip(formatRelativeTime(item.createdAt), "time-chip"); + timeChip.title = formatAbsoluteTime(item.createdAt); + meta.appendChild(timeChip); + + var hostname = item.hostname || utils.getHostnameFromUrl(item.url || ""); + if (hostname) { + meta.appendChild(createHistoryChip(hostname, "domain-chip")); + } + + if (item.replayCount) { + meta.appendChild(createHistoryChip("Replay ×" + item.replayCount, "replay-chip")); + } + + return meta; + } + + function formatRelativeTime(value) { + var delta = Date.now() - Number(value || 0); + if (!Number.isFinite(delta) || delta < 60000) { + return t("history_recency_now", "Just now"); + } + + var minutes = Math.round(delta / 60000); + if (minutes < 60) { + return minutes + "m ago"; + } + + var hours = Math.round(minutes / 60); + if (hours < 24) { + return hours + "h ago"; + } + + return Math.round(hours / 24) + "d ago"; + } + + function formatAbsoluteTime(value) { + var timestamp = Number(value || 0); + if (!Number.isFinite(timestamp) || !timestamp) { + return ""; + } + + try { + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); + } catch (error) { + return new Date(timestamp).toLocaleString(); + } + } + + function createHistoryChip(label, extraClass) { + var chip = document.createElement("span"); + chip.className = "history-chip" + (extraClass ? " " + extraClass : ""); + chip.textContent = label; + return chip; + } + + function normalizeSourceClass(source) { + if (source == "click") return "extension"; + if (source == "shortcut") return "shortcut"; + if (source == "history") return "history"; + if (source == "native") return "native"; + return "unknown"; + } + + async function replayHistory(item) { + try { + await navigator.clipboard.writeText(item.text); + showStatus(t("copy_history_recopied", "Copied from history")); + await recordReplayUsage(item, "historyReplayCopy"); + } catch (error) { + showStatus(error.message || "Clipboard error"); + } + } + + async function deleteHistoryItem(historyId) { + var current = await chrome.storage.local.get({ copyHistory: [] }); + var nextHistory = utils.deleteHistoryEntries(current.copyHistory, [historyId]); + selectedHistoryIds.delete(historyId); + await chrome.storage.local.set({ copyHistory: nextHistory }); + await renderHistoryAndAnalytics(); + showStatus(t("history_deleted", "History item deleted")); + } + + async function recordReplayUsage(item, analyticsType) { + var current = await chrome.storage.local.get({ + copyHistory: [], + copyAnalytics: utils.DEFAULT_ANALYTICS, + }); + var hostname = item.hostname || utils.getHostnameFromUrl(item.url || ""); + var nextHistory = utils.pushHistoryEntry(current.copyHistory, { + text: item.text, + snippet: item.snippet || item.text, + source: "history", + mode: "copy", + url: item.url || "", + hostname: hostname, + }, settings.copyHistoryLimit); + var nextAnalytics = utils.recordAnalyticsEvent(current.copyAnalytics, { + type: analyticsType, + hostname: hostname, + toastKind: "status", + }); + + await chrome.storage.local.set({ + copyHistory: nextHistory, + copyAnalytics: nextAnalytics, + }); + } + + async function clearHistory() { + await chrome.storage.local.set({ copyHistory: [] }); + selectedHistoryIds.clear(); + bulkSelectionMode = false; + await renderHistoryAndAnalytics(); + showStatus(t("copy_history_cleared", "History cleared")); + } + + async function resetAnalytics() { + await chrome.storage.local.set({ copyAnalytics: utils.DEFAULT_ANALYTICS }); + await renderHistoryAndAnalytics(); + showStatus(t("analytics_reset_done", "Analytics reset")); + } + + function applyMessages() { + setText("settings_eyebrow", "Settings"); + setText("settings_title", "Copy text with Alt-Click"); + setText("settings_intro", "Configure how copy gestures, hover previews, safe-mode protections, and history behave across websites."); + setText("tab_general", "General"); + setText("tab_sites", "Sites"); + setText("tab_feedback", "Feedback"); + setText("tab_language", "Language"); + setText("tab_history", "History"); + setText("general_section_title", "General controls"); + setText("meta_key_label", "Copy operation"); + setText("meta_key_help", "Hold your copy modifier and click to copy page content quickly."); + setText("preview_enabled_label", "Hover preview"); + setText("preview_enabled_help", "Show the dashed outline overlay while holding the copy modifier."); + setText("avoid_editable_label", "Skip editable apps"); + setText("avoid_editable_help", "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive."); + setText("keyboard_shortcut_enabled_label", "Keyboard shortcut mode"); + setText("keyboard_shortcut_enabled_help", "Allow the browser shortcut to copy the hovered target or the focused element without clicking."); + setText("keyboard_shortcut_hint", "You can customize the extension shortcut in chrome://extensions/shortcuts."); + setText("sites_section_title", "Excluded domains"); + setText("excluded_domains_add_label", "Add domain"); + setText("add_domain_button", "Add domain"); + document.getElementById("domain_input").placeholder = t("excluded_domains_add_placeholder", "example.com"); + setText("excluded_domains_import_label", "Bulk paste domains"); + setText("excluded_domains_import_help", "Paste one domain per line and click Apply."); + setText("apply_bulk_button", "Apply list"); + setText("excluded_domains_help", "Excluded domains disable both hover previews and copy actions."); + setText("feedback_title", "Feedback behavior"); + setText("toast_duration_label", "Feedback duration"); + setText("toast_duration_help", "Custom duration for the floating copy feedback."); + setText("toast_duration_unit", "ms"); + setText("language_title", "Language"); + setText("ui_language_label", "Extension language"); + setText("ui_language_help", "Auto follows the browser locale. English and Vietnamese are available as manual overrides."); + setText("ui_language_auto", "Auto"); + setText("ui_language_en", "English"); + setText("ui_language_vi", "Tiếng Việt"); + setText("history_title", "Copy history"); + setText("analytics_title", "Local analytics"); + setText("reset_analytics_button", "Reset analytics"); + setText("analytics_total_actions", "Total actions"); + setText("analytics_append_actions", "Selection-first copies"); + setText("analytics_native_actions", "Native copies"); + setText("analytics_shortcut_actions", "Shortcut usage"); + setText("analytics_blocked_actions", "Blocked attempts"); + setText("analytics_toast_events", "Toast events"); + setText("analytics_top_domains", "Top domains"); + setText("history_search_label", "Search history"); + document.getElementById("history_search").placeholder = t("history_search_placeholder", "Search copied text or hostname"); + setText("history_filter_source_label", "Source"); + setText("history_filter_mode_label", "Mode"); + setText("history_filter_domain_label", "Domain"); + setText("history_sort_label", "Sort by"); + setText("history_group_label", "Group by"); + setHistorySelectOptions(); + setText("copy_history_limit_label", "Saved history items"); + setText("copy_history_limit_help", "How many recent copied entries should be kept locally."); + setText("history_bulk_toggle_button", "Select items"); + setText("history_bulk_cancel_button", "Cancel selection"); + setText("history_bulk_copy_button", "Copy selected"); + setText("history_bulk_delete_button", "Delete selected"); + setText("clear_history_button", "Clear history"); + } + + function setHistorySelectOptions() { + setSelectOptionText("history_source_filter", "all", t("history_filter_all", "All")); + setSelectOptionText("history_source_filter", "click", t("history_filter_click", "Click")); + setSelectOptionText("history_source_filter", "shortcut", t("history_filter_shortcut", "Shortcut")); + setSelectOptionText("history_source_filter", "history", t("history_filter_history", "History replay")); + setSelectOptionText("history_source_filter", "native", t("history_filter_native", "Native copy")); + setSelectOptionText("history_mode_filter", "all", t("history_filter_all", "All")); + setSelectOptionText("history_mode_filter", "copy", t("history_filter_copy", "Copy")); + setSelectOptionText("history_domain_filter", "all", t("history_filter_all", "All")); + setSelectOptionText("history_sort", "newest", t("history_sort_newest", "Newest")); + setSelectOptionText("history_sort", "oldest", t("history_sort_oldest", "Oldest")); + setSelectOptionText("history_sort", "replayed", t("history_sort_replayed", "Most replayed")); + setSelectOptionText("history_group", "none", t("history_group_none", "None")); + setSelectOptionText("history_group", "domain", t("history_group_domain", "Domain")); + setSelectOptionText("history_group", "source", t("history_group_source", "Source")); + setSelectOptionText("history_group", "date", t("history_group_date", "Date")); + } + + function enableBulkSelection() { + bulkSelectionMode = true; + selectedHistoryIds.clear(); + renderHistoryAndAnalytics(); + } + + function disableBulkSelection() { + bulkSelectionMode = false; + selectedHistoryIds.clear(); + updateBulkToolbar(); + renderHistoryAndAnalytics(); + showStatus(t("history_selection_cleared", "Selection cleared")); + } + + async function bulkDeleteHistory() { + if (!selectedHistoryIds.size) { + return; + } + var current = await chrome.storage.local.get({ copyHistory: [] }); + var nextHistory = utils.deleteHistoryEntries(current.copyHistory, Array.from(selectedHistoryIds)); + await chrome.storage.local.set({ copyHistory: nextHistory }); + selectedHistoryIds.clear(); + bulkSelectionMode = false; + await renderHistoryAndAnalytics(); + showStatus(t("history_bulk_done", "Bulk action completed")); + } + + async function bulkReplayHistory() { + if (!selectedHistoryIds.size) { + return; + } + + var current = await chrome.storage.local.get({ copyHistory: [] }); + var selectedItems = (Array.isArray(current.copyHistory) ? current.copyHistory : []).filter(function (item) { + return item && selectedHistoryIds.has(item.id); + }); + var combinedText = selectedItems.map(function (item) { return item.text; }).join("\n\n").trim(); + if (!combinedText) { + return; + } + + try { + await navigator.clipboard.writeText(combinedText); + showStatus(t("history_bulk_done", "Bulk action completed")); + } catch (error) { + showStatus(error.message || "Clipboard error"); + } + } + + function updateBulkToolbar() { + var hasSelection = selectedHistoryIds.size > 0; + document.getElementById("history_bulk_toggle_button").hidden = bulkSelectionMode; + document.getElementById("history_bulk_cancel_button").hidden = !bulkSelectionMode; + document.getElementById("history_bulk_delete_button").hidden = !bulkSelectionMode; + document.getElementById("history_bulk_copy_button").hidden = !bulkSelectionMode; + document.getElementById("history_bulk_delete_button").disabled = !hasSelection; + document.getElementById("history_bulk_copy_button").disabled = !hasSelection; + } + + function getGroupLabel(group) { + if (historyFilters.group == "source") { + var sourceMap = { + click: t("copy_history_source_click", "Extension click"), + shortcut: t("copy_history_source_shortcut", "Extension shortcut"), + history: t("copy_history_source_history", "History replay"), + native: t("copy_history_source_native", "Native copy"), + }; + return sourceMap[group.key] || group.label; + } + + if (historyFilters.group == "date") { + var today = new Date().toISOString().slice(0, 10); + var yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + if (group.key == today) return "Today"; + if (group.key == yesterday) return "Yesterday"; + return "Earlier"; + } + + return group.label; + } + + function setSelectOptionText(selectId, value, text) { + var select = document.getElementById(selectId); + var option = select && select.querySelector('option[value="' + value + '"]'); + if (option) { + option.textContent = text; + } + } + + function activateTab(tabId) { + document.querySelectorAll(".tab-button").forEach(function (button) { + var isActive = button.dataset.tab == tabId; + button.classList.toggle("active", isActive); + button.setAttribute("aria-selected", String(isActive)); + button.setAttribute("tabindex", isActive ? "0" : "-1"); + }); + + document.querySelectorAll(".tab-panel").forEach(function (panel) { + panel.classList.toggle("active", panel.dataset.panel == tabId); + }); + } + + function showStatus(message) { + var status = document.getElementById("save_status"); + status.textContent = message; + clearTimeout(statusTimerId); + statusTimerId = setTimeout(function () { + status.textContent = ""; + }, 1800); + } + + function setText(id, fallback) { + var element = document.getElementById(id); + if (element) { + element.textContent = t(id, fallback); + } + } + + function t(key, fallback) { + return utils.translate(settings, key, chrome.i18n.getMessage(key) || fallback, chrome.i18n.getUILanguage()); + } +})(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..05b48f4 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "copy-text-without-selecting", + "version": "2.4.0", + "private": true, + "description": "Manifest V3 browser extension for copying text with modifier-assisted clicks.", + "scripts": { + "test": "node --test" + } +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..0757fdd --- /dev/null +++ b/popup.html @@ -0,0 +1,529 @@ + + + + + + Copy text with Alt-Click + + + +
+
+ + + +
+ +
+
+
+ +
-
+
+ Unsupported +
+ +
+ +
+
+ + +
+ + + + + +
+ + +
+ +
+ + + +
+
+ +
+
+ + +
+
+
+ + +
+ + + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..374f78b --- /dev/null +++ b/popup.js @@ -0,0 +1,383 @@ +(function () { + var utils = globalThis.CopyTextUtils; + var currentHostname = ""; + var settings = utils ? utils.mergeSettings() : null; + + if (!utils) { + console.error("CopyTextUtils is not available."); + return; + } + + var elements = { + site: document.getElementById("current_site"), + siteStatus: document.getElementById("current_site_status"), + toggleSite: document.getElementById("toggle_site"), + metaKey: document.getElementById("popup_meta_key"), + previewEnabled: document.getElementById("popup_preview_enabled"), + avoidEditable: document.getElementById("popup_avoid_editable"), + toastDuration: document.getElementById("popup_toast_duration"), + copyHistoryLimit: document.getElementById("popup_copy_history_limit"), + openOptions: document.getElementById("open_options"), + clearHistory: document.getElementById("clear_history"), + historyList: document.getElementById("history_list"), + status: document.getElementById("popup_status"), + }; + + document.addEventListener("DOMContentLoaded", initializePopup); + + async function initializePopup() { + await restoreSettings(); + applyMessages(); + await Promise.all([detectCurrentSite(), renderHistory()]); + + elements.metaKey.addEventListener("change", saveSettings); + elements.previewEnabled.addEventListener("change", saveSettings); + elements.avoidEditable.addEventListener("change", saveSettings); + elements.toastDuration.addEventListener("input", saveSettings); + elements.toastDuration.addEventListener("change", saveSettings); + elements.copyHistoryLimit.addEventListener("input", saveSettings); + elements.copyHistoryLimit.addEventListener("change", saveSettings); + elements.toggleSite.addEventListener("click", toggleCurrentSite); + elements.clearHistory.addEventListener("click", clearHistory); + elements.openOptions.addEventListener("click", function () { + chrome.runtime.openOptionsPage(); + }); + + chrome.storage.onChanged.addListener(function (changes, areaName) { + if (areaName == "sync") { + restoreSettings().then(function () { + applyMessages(); + refreshSiteState(); + renderHistory(); + }); + } + + if (areaName == "local" && changes.copyHistory) { + renderHistory(); + } + }); + } + + function applyMessages() { + setText("popup_eyebrow", "Quick controls"); + setText("popup_title", "Copy text with Alt-Click"); + setText("popup_subtitle", "Tweak the current site and the most-used interaction settings without opening the full options page."); + setText("popup_site_label", "Current site"); + setText("popup_modifier_label", "Copy modifier"); + setText("popup_preview_label", "Hover preview"); + setText("popup_preview_hint", "Show the target overlay while the modifier key is held."); + setText("popup_safe_label", "Skip editable apps"); + setText("popup_safe_hint", "Avoid copying inside contenteditable editors and rich text surfaces."); + setText("popup_duration_label", "Feedback duration"); + setText("copy_history_limit_label", "Saved history items"); + setText("popup_append_hint", "Quick controls for copy behavior, preview safety, and recent history."); + setText("popup_history_title", "Recent copies"); + setText("clear_history", "Clear history"); + setText("open_options", "Open full settings"); + } + + async function restoreSettings() { + settings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); + elements.metaKey.value = settings.metaKey; + elements.previewEnabled.checked = settings.previewEnabled; + elements.avoidEditable.checked = settings.avoidEditable; + elements.toastDuration.value = String(settings.toastDurationMs); + if (elements.copyHistoryLimit) { + elements.copyHistoryLimit.value = String(settings.copyHistoryLimit); + } + } + + async function detectCurrentSite() { + var tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + var activeTab = tabs[0]; + currentHostname = activeTab ? utils.getHostnameFromUrl(activeTab.url) : ""; + + if (!currentHostname) { + elements.site.textContent = t("popup_site_unknown", "This page is not scriptable"); + setSiteStatus("unsupported"); + elements.toggleSite.disabled = true; + return; + } + + elements.site.textContent = currentHostname; + elements.toggleSite.disabled = false; + await refreshSiteState(); + } + + async function refreshSiteState() { + var currentSettings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); + var isExcluded = utils.isExcludedHost(currentHostname, currentSettings.excludedDomains); + + setSiteStatus(isExcluded ? "excluded" : "active"); + elements.toggleSite.textContent = isExcluded + ? t("popup_toggle_include", "Allow this site") + : t("popup_toggle_exclude", "Exclude this site"); + } + + function setSiteStatus(status) { + elements.siteStatus.className = "badge " + status; + + switch (status) { + case "active": + elements.siteStatus.textContent = t("popup_site_active", "Active"); + break; + case "excluded": + elements.siteStatus.textContent = t("popup_site_excluded", "Excluded"); + break; + default: + elements.siteStatus.textContent = t("popup_site_unsupported", "Unsupported"); + break; + } + } + + async function saveSettings() { + settings = utils.mergeSettings(Object.assign({}, settings, { + metaKey: elements.metaKey.value, + previewEnabled: elements.previewEnabled.checked, + avoidEditable: elements.avoidEditable.checked, + toastDurationMs: elements.toastDuration.value, + copyHistoryLimit: elements.copyHistoryLimit ? elements.copyHistoryLimit.value : settings.copyHistoryLimit, + })); + + await chrome.storage.sync.set(settings); + showStatus(t("save_status_saved", "Saved")); + } + + async function toggleCurrentSite() { + if (!currentHostname) { + return; + } + + var currentSettings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); + currentSettings.excludedDomains = utils.toggleDomain(currentSettings.excludedDomains, currentHostname); + + await chrome.storage.sync.set(currentSettings); + await refreshSiteState(); + showStatus(t("save_status_saved", "Saved")); + } + + async function renderHistory() { + var current = await chrome.storage.local.get({ copyHistory: [] }); + var history = Array.isArray(current.copyHistory) ? current.copyHistory : []; + elements.historyList.textContent = ""; + + if (!history.length) { + var empty = document.createElement("div"); + empty.className = "empty-state"; + + var icon = document.createElement("div"); + icon.className = "empty-state-icon"; + icon.textContent = "📋"; + + var text = document.createElement("div"); + text.className = "empty-state-text"; + text.textContent = t("popup_history_empty", "No recent copies yet."); + + var hint = document.createElement("div"); + hint.className = "empty-state-hint"; + hint.textContent = t("popup_history_onboarding", "Hold " + (settings.metaKey || "Alt") + " + Click any text to copy it."); + + empty.appendChild(icon); + empty.appendChild(text); + empty.appendChild(hint); + elements.historyList.appendChild(empty); + return; + } + + history.forEach(function (item) { + elements.historyList.appendChild(createHistoryItem(item)); + }); + } + + function createHistoryItem(item) { + var wrapper = document.createElement("div"); + wrapper.className = "history-item"; + + var top = document.createElement("div"); + top.className = "history-top"; + + var snippet = document.createElement("div"); + snippet.className = "history-snippet"; + snippet.textContent = item.snippet || item.text; + + var meta = createHistoryMeta(item); + + var content = document.createElement("div"); + content.className = "stack"; + content.appendChild(snippet); + content.appendChild(meta); + + var actions = document.createElement("div"); + actions.className = "history-actions"; + + var copyButton = document.createElement("button"); + copyButton.className = "button secondary small"; + copyButton.type = "button"; + copyButton.textContent = t("history_copy_button", "Copy again"); + copyButton.addEventListener("click", function () { + replayHistory(item); + }); + + var deleteButton = document.createElement("button"); + deleteButton.className = "button secondary small"; + deleteButton.type = "button"; + deleteButton.textContent = t("history_delete_button", "Delete"); + deleteButton.addEventListener("click", function () { + deleteHistoryItem(item.id); + }); + + actions.appendChild(copyButton); + actions.appendChild(deleteButton); + top.appendChild(content); + wrapper.appendChild(top); + wrapper.appendChild(actions); + + return wrapper; + } + + function createHistoryMeta(item) { + var sourceMap = { + click: t("copy_history_source_click", "Extension click"), + shortcut: t("copy_history_source_shortcut", "Extension shortcut"), + history: t("copy_history_source_history", "History replay"), + native: t("copy_history_source_native", "Native copy"), + }; + + var meta = document.createElement("div"); + meta.className = "history-meta"; + meta.appendChild(createHistoryChip(sourceMap[item.source] || item.source || t("copy_history_source_click", "Extension click"), "source-" + normalizeSourceClass(item.source))); + + var timeChip = createHistoryChip(formatRelativeTime(item.createdAt), "time-chip"); + timeChip.title = formatAbsoluteTime(item.createdAt); + meta.appendChild(timeChip); + + var host = item.hostname || (item.url ? utils.getHostnameFromUrl(item.url) : ""); + if (host) { + meta.appendChild(createHistoryChip(host, "domain-chip")); + } + + return meta; + } + + function formatRelativeTime(value) { + var delta = Date.now() - Number(value || 0); + if (!Number.isFinite(delta) || delta < 60000) { + return t("history_recency_now", "Just now"); + } + + var minutes = Math.round(delta / 60000); + if (minutes < 60) { + return minutes + "m ago"; + } + + var hours = Math.round(minutes / 60); + if (hours < 24) { + return hours + "h ago"; + } + + return Math.round(hours / 24) + "d ago"; + } + + function formatAbsoluteTime(value) { + var timestamp = Number(value || 0); + if (!Number.isFinite(timestamp) || !timestamp) { + return ""; + } + + try { + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); + } catch (error) { + return new Date(timestamp).toLocaleString(); + } + } + + function createHistoryChip(label, extraClass) { + var chip = document.createElement("span"); + chip.className = "history-chip" + (extraClass ? " " + extraClass : ""); + chip.textContent = label; + return chip; + } + + function normalizeSourceClass(source) { + if (source == "click") return "extension"; + if (source == "shortcut") return "shortcut"; + if (source == "history") return "history"; + if (source == "native") return "native"; + return "unknown"; + } + + async function replayHistory(item) { + try { + await navigator.clipboard.writeText(item.text); + showStatus(t("copy_history_recopied", "Copied from history")); + await recordReplayUsage(item, "historyReplayCopy"); + } catch (error) { + showStatus(error.message || "Clipboard error"); + } + } + + async function recordReplayUsage(item, analyticsType) { + var current = await chrome.storage.local.get({ + copyHistory: [], + copyAnalytics: utils.DEFAULT_ANALYTICS, + }); + var hostname = item.hostname || utils.getHostnameFromUrl(item.url || ""); + var nextHistory = utils.pushHistoryEntry(current.copyHistory, { + text: item.text, + snippet: item.snippet || item.text, + source: "history", + mode: "copy", + url: item.url || "", + hostname: hostname, + }, settings.copyHistoryLimit); + var nextAnalytics = utils.recordAnalyticsEvent(current.copyAnalytics, { + type: analyticsType, + hostname: hostname, + toastKind: "status", + }); + + await chrome.storage.local.set({ + copyHistory: nextHistory, + copyAnalytics: nextAnalytics, + }); + } + + async function clearHistory() { + await chrome.storage.local.set({ copyHistory: [] }); + await renderHistory(); + showStatus(t("copy_history_cleared", "History cleared")); + } + + async function deleteHistoryItem(historyId) { + var current = await chrome.storage.local.get({ copyHistory: [] }); + var nextHistory = utils.deleteHistoryEntries(current.copyHistory, [historyId]); + await chrome.storage.local.set({ copyHistory: nextHistory }); + await renderHistory(); + showStatus(t("history_deleted", "History item deleted")); + } + + function showStatus(message) { + elements.status.textContent = message; + clearTimeout(showStatus.timerId); + showStatus.timerId = setTimeout(function () { + elements.status.textContent = ""; + }, 1600); + } + + function setText(id, fallback) { + var element = document.getElementById(id); + if (element) { + element.textContent = t(id, fallback); + } + } + + function t(key, fallback) { + return utils.translate(settings, key, chrome.i18n.getMessage(key) || fallback, chrome.i18n.getUILanguage()); + } +})(); diff --git a/shared.js b/shared.js new file mode 100644 index 0000000..b6d37cf --- /dev/null +++ b/shared.js @@ -0,0 +1,506 @@ +(function (root, factory) { + var api = factory(); + if (typeof module !== "undefined" && module.exports) { + module.exports = api; + } + root.CopyTextUtils = api; +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + var DEFAULT_SETTINGS = { + metaKey: "Alt", + excludedDomains: [], + previewEnabled: true, + avoidEditable: true, + toastDurationMs: 1400, + uiLanguage: "auto", + copyHistoryLimit: 20, + keyboardShortcutEnabled: true + }; + var VI_RUNTIME_OVERRIDES = { + meta_key_help: "Giữ phím copy rồi click để copy nhanh nội dung trên trang.", + analytics_append_actions: "Lượt copy theo selection", + popup_append_hint: "Dùng popup này để điều chỉnh nhanh hành vi copy, độ an toàn và lịch sử gần đây." + }; + + var DEFAULT_ANALYTICS = { + totals: { + totalActions: 0, + copied: 0, + nativeCopies: 0, + selectionCopies: 0, + shortcuts: 0, + historyReplayCopy: 0, + historyPinnedCount: 0, + historyReplayCount: 0, + excludedBlocked: 0, + editableSkipped: 0 + }, + toastCounts: { copied: 0, status: 0 }, + domainStats: {}, + lastUpdatedAt: 0 + }; + + var SUPPORTED_META_KEYS = ["Alt", "Ctrl", "Shift"]; + var SUPPORTED_UI_LANGUAGES = ["auto", "en", "vi"]; + var DEFAULT_COPY_HISTORY_LIMIT = 20; + var MAX_COPY_HISTORY_LIMIT = 9999; + var VI_MESSAGES = { + save_status_saved: "Đã lưu", + settings_eyebrow: "Thiết lập", + settings_title: "Copy text with Alt-Click", + settings_intro: "Cấu hình thao tác copy, hover preview, chế độ an toàn và lịch sử trên các website.", + tab_general: "Chung", tab_sites: "Website", tab_feedback: "Phản hồi", tab_language: "Ngôn ngữ", tab_history: "Lịch sử", + general_section_title: "Điều khiển chung", sites_section_title: "Domain loại trừ", + meta_key_label: "Phím thao tác copy", meta_key_help: "Chế độ append dùng thêm phím Shift cùng với phím copy bạn chọn.", + preview_enabled_label: "Hover preview", preview_enabled_help: "Hiển thị khung nét đứt trên phần tử đích khi giữ phím copy.", + avoid_editable_label: "Bỏ qua vùng có thể chỉnh sửa", avoid_editable_help: "Tránh kích hoạt copy trong editor contenteditable và vùng nhập liệu nâng cao.", + keyboard_shortcut_enabled_label: "Chế độ phím tắt", keyboard_shortcut_enabled_help: "Cho phép dùng phím tắt của extension để copy phần tử đang hover hoặc đang focus mà không cần click.", + keyboard_shortcut_hint: "Bạn có thể đổi phím tắt trong chrome://extensions/shortcuts.", + excluded_domains_add_label: "Thêm domain", excluded_domains_add_placeholder: "vi-du.com", excluded_domains_empty: "Chưa có domain nào bị loại trừ.", + excluded_domains_import_label: "Dán nhiều domain", excluded_domains_import_help: "Dán mỗi domain trên một dòng rồi bấm Áp dụng.", + excluded_domains_help: "Các domain bị loại trừ sẽ tắt cả hover preview và hành động copy.", + add_domain_button: "Thêm domain", apply_bulk_button: "Áp dụng danh sách", domain_remove_button: "Xóa", + feedback_title: "Hiển thị phản hồi", toast_duration_label: "Thời lượng phản hồi", toast_duration_help: "Tùy chỉnh thời gian hiển thị hiệu ứng copy nổi.", toast_duration_unit: "ms", + language_title: "Ngôn ngữ", ui_language_label: "Ngôn ngữ extension", ui_language_help: "Auto dùng ngôn ngữ trình duyệt. Có thể ép sang English hoặc Tiếng Việt.", + ui_language_auto: "Tự động", ui_language_en: "English", ui_language_vi: "Tiếng Việt", + history_title: "Lịch sử copy", copy_history_limit_label: "Số mục lịch sử lưu lại", copy_history_limit_help: "Số mục copy gần đây sẽ được giữ trong bộ nhớ cục bộ.", + copy_history_empty: "Chưa có mục copy nào.", copy_history_recopied: "Đã copy lại từ lịch sử", copy_history_appended: "Đã append từ lịch sử", copy_history_cleared: "Đã xóa lịch sử", + copy_history_source_click: "Click từ extension", copy_history_source_shortcut: "Phím tắt extension", copy_history_source_history: "Replay lịch sử", copy_history_source_native: "Copy mặc định", + history_copy_button: "Copy lại", history_append_button: "Append", history_delete_button: "Xóa", history_expand_button: "Xem đầy đủ", history_collapse_button: "Ẩn bớt", + history_pin_button: "Ghim", history_unpin_button: "Bỏ ghim", history_bulk_select_button: "Chọn nhiều mục", history_bulk_cancel_button: "Hủy chọn", + history_bulk_delete_button: "Xóa mục đã chọn", history_bulk_copy_button: "Copy mục đã chọn", history_bulk_append_button: "Append mục đã chọn", + history_search_label: "Tìm trong lịch sử", history_search_placeholder: "Tìm theo nội dung hoặc hostname", + history_filter_source_label: "Nguồn", history_filter_mode_label: "Chế độ", history_filter_domain_label: "Domain", history_filter_all: "Tất cả", + history_filter_click: "Click từ extension", history_filter_shortcut: "Phím tắt extension", history_filter_history: "Replay lịch sử", history_filter_native: "Copy mặc định", + history_filter_copy: "Copy", history_filter_append: "Append", history_filter_append_fallback: "Append fallback", history_filter_pinned_label: "Chỉ hiện mục ghim", + history_group_label: "Nhóm theo", history_group_none: "Không nhóm", history_group_domain: "Domain", history_group_source: "Nguồn", history_group_date: "Ngày", + history_sort_label: "Sắp xếp", history_sort_newest: "Mới nhất", history_sort_oldest: "Cũ nhất", history_sort_replayed: "Replay nhiều nhất", history_sort_pinned: "Ưu tiên ghim", + history_recency_now: "Vừa xong", history_no_results: "Không có mục lịch sử phù hợp.", + analytics_title: "Phân tích nội bộ", analytics_total_actions: "Tổng hành động", analytics_append_actions: "Lượt append", analytics_native_actions: "Lượt copy mặc định", + analytics_selection_actions: "Lượt copy theo selection", analytics_shortcut_actions: "Lượt dùng phím tắt", analytics_blocked_actions: "Lượt bị chặn", analytics_toast_events: "Lượt toast hiển thị", + analytics_top_domains: "Domain dùng nhiều nhất", analytics_empty_domains: "Chưa có hoạt động domain nào.", analytics_reset_done: "Đã xóa analytics", reset_analytics_button: "Xóa analytics", + history_deleted: "Đã xóa mục lịch sử", history_selection_cleared: "Đã bỏ chọn", history_bulk_done: "Đã hoàn thành thao tác hàng loạt", + popup_eyebrow: "Điều khiển nhanh", popup_title: "Copy text with Alt-Click", popup_subtitle: "Điều chỉnh nhanh website hiện tại và các thiết lập hay dùng mà không cần mở trang settings đầy đủ.", + popup_site_label: "Website hiện tại", popup_modifier_label: "Phím copy", popup_preview_label: "Hover preview", popup_preview_hint: "Hiển thị overlay trên phần tử đích khi giữ phím copy.", + popup_safe_label: "Bỏ qua editor", popup_safe_hint: "Tránh copy trong contenteditable và rich text editor.", popup_duration_label: "Thời lượng phản hồi", popup_append_hint: "Chế độ append vẫn dùng được với Shift + phím copy + Click.", + popup_site_active: "Đang bật", popup_site_excluded: "Đã loại trừ", popup_site_unsupported: "Không hỗ trợ", popup_site_unknown: "Trang này không thể chạy script", + popup_toggle_include: "Cho phép website này", popup_toggle_exclude: "Loại trừ website này", popup_history_title: "Lịch sử gần đây", popup_history_empty: "Chưa có mục copy gần đây.", + clear_history: "Xóa lịch sử", open_options: "Mở cài đặt đầy đủ", + toast_copied: "Đã copy!", toast_appended: "Đã append!", toast_append_fallback: "Đã copy thay thế", + shortcut_unavailable: "Không có phần tử hover/focus nào để copy.", unsupported_surface_status: "Đã bỏ qua vùng đang chỉnh sửa" + }; + + function normalizeDomain(value) { + var trimmed = String(value || "").trim().toLowerCase(); + if (!trimmed) return ""; + var normalized = trimmed.replace(/^\.+/, ""); + try { + var candidate = normalized.includes("://") ? normalized : "https://" + normalized; + normalized = new URL(candidate).hostname.toLowerCase(); + } catch (error) { + normalized = normalized.split(/[/?#]/, 1)[0]; + } + return normalized.replace(/^\.+/, ""); + } + + function normalizeExcludedDomains(input) { + var seen = new Set(); + return (Array.isArray(input) ? input : String(input || "").split(/\r?\n/)).map(normalizeDomain).filter(function (domain) { + if (!domain || seen.has(domain)) return false; + seen.add(domain); + return true; + }); + } + + function normalizeMetaKey(value) { return SUPPORTED_META_KEYS.includes(value) ? value : DEFAULT_SETTINGS.metaKey; } + function normalizeToastDuration(value) { + var duration = Number(value); + return Number.isFinite(duration) ? Math.min(8000, Math.max(300, Math.round(duration))) : DEFAULT_SETTINGS.toastDurationMs; + } + function normalizeUiLanguage(value) { return SUPPORTED_UI_LANGUAGES.includes(value) ? value : DEFAULT_SETTINGS.uiLanguage; } + function normalizeCopyHistoryLimit(value) { + var limit = Number(value); + return Number.isFinite(limit) ? Math.min(MAX_COPY_HISTORY_LIMIT, Math.max(0, Math.round(limit))) : DEFAULT_COPY_HISTORY_LIMIT; + } + + function mergeSettings(rawSettings) { + var input = rawSettings || {}; + return { + metaKey: normalizeMetaKey(input.metaKey), + excludedDomains: normalizeExcludedDomains(input.excludedDomains), + previewEnabled: input.previewEnabled !== false, + avoidEditable: input.avoidEditable !== false, + toastDurationMs: normalizeToastDuration(input.toastDurationMs), + uiLanguage: normalizeUiLanguage(input.uiLanguage), + copyHistoryLimit: normalizeCopyHistoryLimit(input.copyHistoryLimit), + keyboardShortcutEnabled: input.keyboardShortcutEnabled !== false + }; + } + + function isExcludedHost(hostname, excludedDomains) { + var normalizedHost = normalizeDomain(hostname); + return normalizeExcludedDomains(excludedDomains).some(function (domain) { + return normalizedHost === domain || normalizedHost.endsWith("." + domain); + }); + } + + function buildExcludeMatches(excludedDomains) { + var patterns = new Set(); + normalizeExcludedDomains(excludedDomains).forEach(function (domain) { + patterns.add("*://" + domain + "/*"); + patterns.add("*://*." + domain + "/*"); + }); + return Array.from(patterns); + } + + function getCopyMode(metaKey, event) { + var normalizedMetaKey = normalizeMetaKey(metaKey); + if (!isPrimaryModifierPressed(normalizedMetaKey, event)) return null; + return "copy"; + } + + function isPrimaryModifierPressed(metaKey, event) { + switch (normalizeMetaKey(metaKey)) { + case "Alt": return !!event.altKey; + case "Ctrl": return !!event.ctrlKey; + case "Shift": return !!event.shiftKey; + default: return false; + } + } + + function isModifierKeyEvent(event) { + return event.key === "Alt" || event.key === "Control" || event.key === "Shift"; + } + + function isEditableSurface(element) { + if (!element || typeof element.closest !== "function") return false; + var tagName = String(element.nodeName || "").toUpperCase(); + if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") return false; + return !!element.closest(["[contenteditable]:not([contenteditable='false'])","[role='textbox']", ".CodeMirror", ".cm-editor", ".ProseMirror", ".monaco-editor", ".ace_editor"].join(",")); + } + + function toggleDomain(excludedDomains, domain) { + var normalizedDomains = normalizeExcludedDomains(excludedDomains); + var normalizedDomain = normalizeDomain(domain); + if (!normalizedDomain) return normalizedDomains; + if (normalizedDomains.includes(normalizedDomain)) { + return normalizedDomains.filter(function (item) { return item !== normalizedDomain; }); + } + return normalizedDomains.concat(normalizedDomain).sort(); + } + + function getHostnameFromUrl(url) { + try { return new URL(url).hostname.toLowerCase(); } catch (error) { return ""; } + } + + function getTextSnippet(text, maxLength) { + var collapsed = String(text || "").replace(/\s+/g, " ").trim(); + var limit = Number.isFinite(Number(maxLength)) ? Math.max(4, Math.round(Number(maxLength))) : 80; + if (!collapsed) return "(empty)"; + return collapsed.length > limit ? collapsed.slice(0, limit - 3) + "..." : collapsed; + } + + function getLanguage(settingsOrLanguage, browserLanguage) { + var requested = typeof settingsOrLanguage === "string" ? settingsOrLanguage : normalizeUiLanguage((settingsOrLanguage || {}).uiLanguage); + if (requested !== "auto") return requested; + var normalizedBrowserLanguage = String(browserLanguage || "").toLowerCase(); + if (normalizedBrowserLanguage.startsWith("vi")) return "vi"; + if (normalizedBrowserLanguage.startsWith("en")) return "en"; + return "auto"; + } + + function translate(settingsOrLanguage, key, fallback, browserLanguage) { + var language = getLanguage(settingsOrLanguage, browserLanguage); + if (language === "vi" && VI_RUNTIME_OVERRIDES[key]) return VI_RUNTIME_OVERRIDES[key]; + if (language === "vi" && VI_MESSAGES[key]) return VI_MESSAGES[key]; + return fallback || key; + } + + function normalizeHistoryEntry(entry) { + if (!entry) return null; + var normalizedText = String(entry.text || "").trim(); + if (!normalizedText) return null; + return { + id: String(entry.id || (Date.now() + "-" + Math.random().toString(16).slice(2))), + text: normalizedText, + snippet: getTextSnippet(entry.snippet || normalizedText), + createdAt: Number(entry.createdAt || Date.now()), + source: String(entry.source || "click"), + url: String(entry.url || ""), + hostname: normalizeDomain(entry.hostname || getHostnameFromUrl(entry.url || "")), + mode: String(entry.mode || "copy"), + pinned: !!entry.pinned, + replayCount: Number(entry.replayCount || 0), + lastReplayedAt: Number(entry.lastReplayedAt || 0) + }; + } + + function pushHistoryEntry(entries, entry, limit) { + var nextEntries = (Array.isArray(entries) ? entries : []).map(normalizeHistoryEntry).filter(Boolean); + var maxItems = normalizeCopyHistoryLimit(limit || DEFAULT_COPY_HISTORY_LIMIT); + var normalizedEntry = normalizeHistoryEntry(entry); + if (!normalizedEntry || maxItems === 0) return nextEntries.slice(0, maxItems); + + var existing = nextEntries.find(function (item) { return item.text === normalizedEntry.text; }); + if (existing) { + existing.snippet = normalizedEntry.snippet; + existing.createdAt = normalizedEntry.createdAt; + existing.source = normalizedEntry.source; + existing.url = normalizedEntry.url; + existing.hostname = normalizedEntry.hostname; + existing.mode = normalizedEntry.mode; + existing.pinned = existing.pinned || normalizedEntry.pinned; + nextEntries = nextEntries.filter(function (item) { return item.id !== existing.id; }); + nextEntries.unshift(existing); + } else { + nextEntries.unshift(normalizedEntry); + } + return nextEntries.slice(0, maxItems); + } + + function updateHistoryEntry(entries, historyId, updater, limit) { + return (Array.isArray(entries) ? entries : []).map(normalizeHistoryEntry).filter(Boolean).map(function (entry) { + if (entry.id !== historyId) return entry; + var updated = typeof updater === "function" ? updater(Object.assign({}, entry)) : entry; + return normalizeHistoryEntry(Object.assign({}, entry, updated || {})); + }).slice(0, normalizeCopyHistoryLimit(limit || DEFAULT_COPY_HISTORY_LIMIT)); + } + + function deleteHistoryEntries(entries, ids) { + var idSet = new Set(Array.isArray(ids) ? ids : [ids]); + return (Array.isArray(entries) ? entries : []).map(normalizeHistoryEntry).filter(function (entry) { + return entry && !idSet.has(entry.id); + }); + } + + function sortHistoryEntries(entries, sortBy) { + var nextEntries = (Array.isArray(entries) ? entries : []).map(normalizeHistoryEntry).filter(Boolean).slice(); + switch (sortBy) { + case "oldest": + return nextEntries.sort(function (left, right) { return left.createdAt - right.createdAt; }); + case "replayed": + return nextEntries.sort(function (left, right) { return (right.replayCount - left.replayCount) || (right.createdAt - left.createdAt); }); + case "pinned": + return nextEntries.sort(function (left, right) { return Number(!!right.pinned) - Number(!!left.pinned) || (right.createdAt - left.createdAt); }); + default: + return nextEntries.sort(function (left, right) { return right.createdAt - left.createdAt; }); + } + } + + function groupHistoryEntries(entries, groupBy) { + if (!groupBy || groupBy === "none") return [{ key: "all", label: "", entries: entries }]; + var groups = new Map(); + (Array.isArray(entries) ? entries : []).map(normalizeHistoryEntry).filter(Boolean).forEach(function (entry) { + var key = "other"; + if (groupBy === "domain") key = entry.hostname || "other"; + else if (groupBy === "source") key = entry.source || "other"; + else if (groupBy === "date") key = new Date(entry.createdAt).toISOString().slice(0, 10); + if (!groups.has(key)) groups.set(key, { key: key, label: key, entries: [] }); + groups.get(key).entries.push(entry); + }); + return Array.from(groups.values()); + } + + function normalizeAnalytics(rawAnalytics) { + var input = rawAnalytics || {}; + var totals = Object.assign({}, DEFAULT_ANALYTICS.totals, input.totals || {}); + var toastCounts = Object.assign({}, DEFAULT_ANALYTICS.toastCounts, input.toastCounts || {}); + var domainStats = {}; + Object.keys(input.domainStats || {}).forEach(function (hostname) { + var normalizedHost = normalizeDomain(hostname); + if (!normalizedHost) return; + var value = input.domainStats[hostname] || {}; + domainStats[normalizedHost] = { + totalActions: Number(value.totalActions || 0), + copied: Number(value.copied || 0), + shortcuts: Number(value.shortcuts || 0), + selectionCopies: Number(value.selectionCopies || 0), + historyReplays: Number(value.historyReplays || 0), + blockedExcluded: Number(value.blockedExcluded || 0), + editableSkipped: Number(value.editableSkipped || 0), + lastUsedAt: Number(value.lastUsedAt || 0) + }; + }); + return { + totals: totals, + toastCounts: toastCounts, + domainStats: domainStats, + lastUpdatedAt: Number(input.lastUpdatedAt || 0) + }; + } + + function ensureDomainStats(analytics, hostname, timestamp) { + if (!hostname) return null; + if (!analytics.domainStats[hostname]) { + analytics.domainStats[hostname] = { + totalActions: 0, + copied: 0, + shortcuts: 0, + selectionCopies: 0, + historyReplays: 0, + blockedExcluded: 0, + editableSkipped: 0, + lastUsedAt: timestamp + }; + } + analytics.domainStats[hostname].lastUsedAt = timestamp; + return analytics.domainStats[hostname]; + } + + function incrementCounter(container, key) { + container[key] = Number(container[key] || 0) + 1; + } + + function recordAnalyticsEvent(rawAnalytics, event) { + var analytics = normalizeAnalytics(rawAnalytics); + var timestamp = Number((event && event.timestamp) || Date.now()); + var hostname = normalizeDomain((event && event.hostname) || getHostnameFromUrl((event && event.url) || "")); + var domainStats = ensureDomainStats(analytics, hostname, timestamp); + var type = String((event && event.type) || ""); + var toastKind = String((event && event.toastKind) || ""); + + switch (type) { + case "copy": + incrementCounter(analytics.totals, "totalActions"); incrementCounter(analytics.totals, "copied"); + if (domainStats) { incrementCounter(domainStats, "totalActions"); incrementCounter(domainStats, "copied"); } + break; + case "nativeCopy": + incrementCounter(analytics.totals, "totalActions"); incrementCounter(analytics.totals, "nativeCopies"); + if (domainStats) { incrementCounter(domainStats, "totalActions"); incrementCounter(domainStats, "copied"); } + break; + case "selectionCopy": + incrementCounter(analytics.totals, "totalActions"); incrementCounter(analytics.totals, "selectionCopies"); + if (domainStats) { incrementCounter(domainStats, "totalActions"); incrementCounter(domainStats, "selectionCopies"); } + break; + case "shortcut": + incrementCounter(analytics.totals, "shortcuts"); + if (domainStats) incrementCounter(domainStats, "shortcuts"); + break; + case "historyReplayCopy": + incrementCounter(analytics.totals, "totalActions"); incrementCounter(analytics.totals, "historyReplayCopy"); incrementCounter(analytics.totals, "historyReplayCount"); + if (domainStats) { incrementCounter(domainStats, "totalActions"); incrementCounter(domainStats, "historyReplays"); } + break; + case "blockedExcluded": + incrementCounter(analytics.totals, "excludedBlocked"); + if (domainStats) incrementCounter(domainStats, "blockedExcluded"); + break; + case "editableSkipped": + incrementCounter(analytics.totals, "editableSkipped"); + if (domainStats) incrementCounter(domainStats, "editableSkipped"); + break; + case "historyPinned": + analytics.totals.historyPinnedCount = Math.max(0, Number(analytics.totals.historyPinnedCount || 0) + 1); + break; + case "historyUnpinned": + analytics.totals.historyPinnedCount = Math.max(0, Number(analytics.totals.historyPinnedCount || 0) - 1); + break; + } + + if (toastKind) { + if (analytics.toastCounts.hasOwnProperty(toastKind)) incrementCounter(analytics.toastCounts, toastKind); + } + analytics.lastUpdatedAt = timestamp; + return analytics; + } + + function getTopDomainStats(rawAnalytics, limit) { + var analytics = normalizeAnalytics(rawAnalytics); + var maxItems = Math.max(1, Number(limit) || 5); + return Object.keys(analytics.domainStats).map(function (hostname) { + var stats = analytics.domainStats[hostname]; + return { + hostname: hostname, + totalActions: stats.totalActions, + copied: stats.copied, + shortcuts: stats.shortcuts, + selectionCopies: stats.selectionCopies, + historyReplays: stats.historyReplays, + blockedExcluded: stats.blockedExcluded, + editableSkipped: stats.editableSkipped, + lastUsedAt: stats.lastUsedAt + }; + }).sort(function (left, right) { + return right.totalActions - left.totalActions || right.lastUsedAt - left.lastUsedAt; + }).slice(0, maxItems); + } + + function filterHistoryEntries(entries, filters) { + var search = String((filters && filters.search) || "").trim().toLowerCase(); + var source = String((filters && filters.source) || "all"); + var mode = String((filters && filters.mode) || "all"); + var hostnameFilter = String((filters && filters.hostname) || "all"); + var hostname = hostnameFilter === "all" ? "" : normalizeDomain(hostnameFilter); + var pinnedOnly = !!(filters && filters.pinnedOnly); + + return (Array.isArray(entries) ? entries : []).map(normalizeHistoryEntry).filter(Boolean).filter(function (item) { + var itemHostname = normalizeDomain(item.hostname || getHostnameFromUrl(item.url || "")); + var haystack = [String(item.text || ""), String(item.snippet || ""), itemHostname].join("\n").toLowerCase(); + if (search && !haystack.includes(search)) return false; + if (source !== "all" && item.source !== source) return false; + if (mode !== "all" && item.mode !== mode) return false; + if (hostname && itemHostname !== hostname) return false; + if (pinnedOnly && !item.pinned) return false; + return true; + }); + } + + function getHistoryHostOptions(entries) { + var seen = new Set(); + return (Array.isArray(entries) ? entries : []).map(function (item) { + return normalizeDomain(item && (item.hostname || getHostnameFromUrl(item.url || ""))); + }).filter(function (hostname) { + if (!hostname || seen.has(hostname)) return false; + seen.add(hostname); + return true; + }).sort(); + } + + function sanitizeText(text) { + return String(text || "") + .replace(/\u200B/g, "") // zero-width spaces + .replace(/\u200C/g, "") // zero-width non-joiner + .replace(/\u200D/g, "") // zero-width joiner + .replace(/\uFEFF/g, "") // BOM + .replace(/\u00A0/g, " ") // non-breaking space → regular space + .replace(/[ \t]+/g, " ") // collapse horizontal whitespace + .replace(/(\r?\n){3,}/g, "\n\n") // max 2 consecutive newlines + .trim(); + } + + return { + DEFAULT_SETTINGS: DEFAULT_SETTINGS, + DEFAULT_ANALYTICS: DEFAULT_ANALYTICS, + SUPPORTED_META_KEYS: SUPPORTED_META_KEYS, + SUPPORTED_UI_LANGUAGES: SUPPORTED_UI_LANGUAGES, + UI_MESSAGES: VI_MESSAGES, + normalizeDomain: normalizeDomain, + normalizeExcludedDomains: normalizeExcludedDomains, + normalizeMetaKey: normalizeMetaKey, + normalizeToastDuration: normalizeToastDuration, + normalizeUiLanguage: normalizeUiLanguage, + normalizeCopyHistoryLimit: normalizeCopyHistoryLimit, + mergeSettings: mergeSettings, + isExcludedHost: isExcludedHost, + buildExcludeMatches: buildExcludeMatches, + getCopyMode: getCopyMode, + isPrimaryModifierPressed: isPrimaryModifierPressed, + isModifierKeyEvent: isModifierKeyEvent, + isEditableSurface: isEditableSurface, + toggleDomain: toggleDomain, + getHostnameFromUrl: getHostnameFromUrl, + getTextSnippet: getTextSnippet, + getLanguage: getLanguage, + translate: translate, + normalizeHistoryEntry: normalizeHistoryEntry, + pushHistoryEntry: pushHistoryEntry, + updateHistoryEntry: updateHistoryEntry, + deleteHistoryEntries: deleteHistoryEntries, + sortHistoryEntries: sortHistoryEntries, + groupHistoryEntries: groupHistoryEntries, + normalizeAnalytics: normalizeAnalytics, + recordAnalyticsEvent: recordAnalyticsEvent, + getTopDomainStats: getTopDomainStats, + filterHistoryEntries: filterHistoryEntries, + getHistoryHostOptions: getHistoryHostOptions, + sanitizeText: sanitizeText, + }; +}); diff --git a/tests/shared.test.js b/tests/shared.test.js new file mode 100644 index 0000000..465b2ba --- /dev/null +++ b/tests/shared.test.js @@ -0,0 +1,277 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const utils = require("../shared.js"); + +test("normalizeDomain strips protocol, paths, and leading dots", function () { + assert.equal(utils.normalizeDomain("https://docs.google.com/document/d/123"), "docs.google.com"); + assert.equal(utils.normalizeDomain(".figma.com"), "figma.com"); + assert.equal(utils.normalizeDomain("sub.example.com/path?q=1"), "sub.example.com"); +}); + +test("normalizeExcludedDomains deduplicates and normalizes values", function () { + assert.deepEqual( + utils.normalizeExcludedDomains([ + "https://docs.google.com", + "docs.google.com", + " .figma.com ", + "" + ]), + ["docs.google.com", "figma.com"] + ); +}); + +test("mergeSettings applies defaults and clamps duration", function () { + assert.deepEqual( + utils.mergeSettings({ + metaKey: "Ctrl", + excludedDomains: "docs.google.com", + previewEnabled: false, + avoidEditable: false, + toastDurationMs: 5000 + }), + { + metaKey: "Ctrl", + excludedDomains: ["docs.google.com"], + previewEnabled: false, + avoidEditable: false, + toastDurationMs: 5000, + uiLanguage: "auto", + copyHistoryLimit: 20, + keyboardShortcutEnabled: true + } + ); +}); + +test("isExcludedHost matches exact domains and subdomains", function () { + const excluded = ["figma.com", "docs.google.com"]; + assert.equal(utils.isExcludedHost("figma.com", excluded), true); + assert.equal(utils.isExcludedHost("www.figma.com", excluded), true); + assert.equal(utils.isExcludedHost("x.docs.google.com", excluded), true); + assert.equal(utils.isExcludedHost("google.com", excluded), false); +}); + +test("buildExcludeMatches generates host and wildcard patterns", function () { + assert.deepEqual( + utils.buildExcludeMatches(["figma.com"]), + ["*://figma.com/*", "*://*.figma.com/*"] + ); +}); + +test("getCopyMode returns copy when the configured modifier is held", function () { + assert.equal(utils.getCopyMode("Alt", { altKey: true, shiftKey: false }), "copy"); + assert.equal(utils.getCopyMode("Shift", { altKey: false, shiftKey: true }), "copy"); + assert.equal(utils.getCopyMode("Ctrl", { ctrlKey: false, shiftKey: false }), null); +}); + +test("toggleDomain adds and removes normalized hostnames", function () { + assert.deepEqual(utils.toggleDomain([], "https://docs.google.com"), ["docs.google.com"]); + assert.deepEqual(utils.toggleDomain(["docs.google.com"], "docs.google.com"), []); +}); + +test("translate resolves manual Vietnamese override and auto language", function () { + assert.equal(utils.translate({ uiLanguage: "vi" }, "popup_site_active", "fallback", "en-US"), "Đang bật"); + assert.equal(utils.translate({ uiLanguage: "auto" }, "popup_site_active", "fallback", "vi-VN"), "Đang bật"); + assert.equal(utils.translate({ uiLanguage: "auto" }, "popup_site_active", "fallback", "ja"), "fallback"); +}); + +test("pushHistoryEntry deduplicates by text and enforces the limit", function () { + const history = utils.pushHistoryEntry([ + { id: "1", text: "Old", snippet: "Old", createdAt: 1, source: "click", mode: "copy", url: "https://example.com" }, + { id: "2", text: "Keep", snippet: "Keep", createdAt: 2, source: "shortcut", mode: "copy", url: "https://example.com" } + ], { + text: "Old", + snippet: "Old", + createdAt: 3, + source: "shortcut", + mode: "copy", + url: "https://example.com/page" + }, 2); + + assert.equal(history.length, 2); + assert.equal(history[0].text, "Old"); + assert.equal(history[0].source, "shortcut"); + assert.equal(history[1].text, "Keep"); +}); + +test("getTextSnippet collapses whitespace and truncates long text", function () { + assert.equal(utils.getTextSnippet(" A long text "), "A long text"); + assert.equal(utils.getTextSnippet("1234567890", 8), "12345..."); +}); + +test("recordAnalyticsEvent increments totals and domain stats", function () { + let analytics = utils.recordAnalyticsEvent(utils.DEFAULT_ANALYTICS, { + type: "copy", + hostname: "docs.google.com", + toastKind: "copied", + }); + analytics = utils.recordAnalyticsEvent(analytics, { + type: "shortcut", + hostname: "docs.google.com", + }); + analytics = utils.recordAnalyticsEvent(analytics, { + type: "blockedExcluded", + hostname: "figma.com", + toastKind: "status", + }); + + assert.equal(analytics.totals.totalActions, 1); + assert.equal(analytics.totals.copied, 1); + assert.equal(analytics.totals.shortcuts, 1); + assert.equal(analytics.totals.excludedBlocked, 1); + assert.equal(analytics.toastCounts.copied, 1); + assert.equal(analytics.toastCounts.status, 1); + assert.equal(analytics.domainStats["docs.google.com"].copied, 1); + assert.equal(analytics.domainStats["docs.google.com"].shortcuts, 1); + assert.equal(analytics.domainStats["figma.com"].blockedExcluded, 1); +}); + +test("recordAnalyticsEvent tracks native copy activity", function () { + const analytics = utils.recordAnalyticsEvent(utils.DEFAULT_ANALYTICS, { + type: "nativeCopy", + hostname: "example.com", + }); + + assert.equal(analytics.totals.totalActions, 1); + assert.equal(analytics.totals.nativeCopies, 1); + assert.equal(analytics.domainStats["example.com"].copied, 1); +}); + +test("filterHistoryEntries applies search, source, mode, and domain filters", function () { + const entries = [ + { text: "Google Docs Guide", snippet: "Google Docs Guide", source: "click", mode: "copy", hostname: "docs.google.com" }, + { text: "Figma Board", snippet: "Figma Board", source: "shortcut", mode: "copy", hostname: "figma.com" }, + { text: "Replay", snippet: "Replay", source: "history", mode: "copy", hostname: "example.com" }, + { text: "Selected native text", snippet: "Selected native text", source: "native", mode: "copy", hostname: "news.ycombinator.com" } + ]; + + assert.equal(utils.filterHistoryEntries(entries, { search: "docs", source: "all", mode: "all", hostname: "all" }).length, 1); + assert.equal(utils.filterHistoryEntries(entries, { search: "", source: "shortcut", mode: "copy", hostname: "figma.com" }).length, 1); + assert.equal(utils.filterHistoryEntries(entries, { search: "", source: "history", mode: "copy", hostname: "all" }).length, 1); + assert.equal(utils.filterHistoryEntries(entries, { search: "native", source: "native", mode: "copy", hostname: "news.ycombinator.com" }).length, 1); +}); + +test("updateHistoryEntry updates replay metadata", function () { + const entries = [ + { id: "a", text: "Alpha", snippet: "Alpha", createdAt: 1, source: "click", mode: "copy", hostname: "example.com", pinned: false, replayCount: 0, lastReplayedAt: 0 } + ]; + + const updated = utils.updateHistoryEntry(entries, "a", function (entry) { + entry.replayCount = 3; + entry.lastReplayedAt = 99; + return entry; + }, 10); + + assert.equal(updated[0].replayCount, 3); + assert.equal(updated[0].lastReplayedAt, 99); +}); + +test("deleteHistoryEntries removes targeted items", function () { + const entries = [ + { id: "a", text: "Alpha", snippet: "Alpha", createdAt: 1, source: "click", mode: "copy", hostname: "example.com" }, + { id: "b", text: "Beta", snippet: "Beta", createdAt: 2, source: "native", mode: "copy", hostname: "example.com" } + ]; + + const remaining = utils.deleteHistoryEntries(entries, ["a"]); + assert.equal(remaining.length, 1); + assert.equal(remaining[0].id, "b"); +}); + +test("sortHistoryEntries supports replay sorting", function () { + const entries = [ + { id: "a", text: "Alpha", snippet: "Alpha", createdAt: 1, source: "click", mode: "copy", hostname: "example.com", pinned: false, replayCount: 1 }, + { id: "b", text: "Beta", snippet: "Beta", createdAt: 3, source: "click", mode: "copy", hostname: "example.com", pinned: false, replayCount: 0 }, + { id: "c", text: "Gamma", snippet: "Gamma", createdAt: 2, source: "click", mode: "copy", hostname: "example.com", pinned: false, replayCount: 5 } + ]; + + assert.equal(utils.sortHistoryEntries(entries, "replayed")[0].id, "c"); +}); + +test("groupHistoryEntries groups by source", function () { + const entries = [ + { id: "a", text: "Alpha", snippet: "Alpha", createdAt: 1, source: "click", mode: "copy", hostname: "example.com" }, + { id: "b", text: "Beta", snippet: "Beta", createdAt: 2, source: "native", mode: "copy", hostname: "example.com" } + ]; + + const groups = utils.groupHistoryEntries(entries, "source"); + assert.equal(groups.length, 2); + assert.equal(groups[0].entries.length, 1); +}); + +test("sanitizeText removes zero-width spaces and BOM", function () { + assert.equal(utils.sanitizeText("Hello\u200BWorld"), "HelloWorld"); + assert.equal(utils.sanitizeText("\uFEFFStart"), "Start"); + assert.equal(utils.sanitizeText("A\u200CB\u200DC"), "ABC"); +}); + +test("sanitizeText collapses horizontal whitespace and trims", function () { + assert.equal(utils.sanitizeText(" Hello World "), "Hello World"); + assert.equal(utils.sanitizeText("Tab\t\tSpace"), "Tab Space"); + assert.equal(utils.sanitizeText(" \t "), ""); +}); + +test("sanitizeText preserves up to 2 consecutive newlines", function () { + assert.equal(utils.sanitizeText("A\n\nB"), "A\n\nB"); + assert.equal(utils.sanitizeText("A\n\n\n\nB"), "A\n\nB"); + assert.equal(utils.sanitizeText("A\n\n\n\n\n\nB"), "A\n\nB"); +}); + +test("sanitizeText converts non-breaking spaces to regular spaces", function () { + assert.equal(utils.sanitizeText("Hello\u00A0World"), "Hello World"); + assert.equal(utils.sanitizeText("Multiple\u00A0\u00A0Nbsp"), "Multiple Nbsp"); +}); + +test("sanitizeText handles empty and null input", function () { + assert.equal(utils.sanitizeText(""), ""); + assert.equal(utils.sanitizeText(null), ""); + assert.equal(utils.sanitizeText(undefined), ""); +}); + +test("pushHistoryEntry deduplicates consecutive identical text", function () { + const first = utils.pushHistoryEntry([], { + text: "Same text", + snippet: "Same text", + createdAt: 1, + source: "click", + mode: "copy", + url: "https://example.com" + }, 20); + + const second = utils.pushHistoryEntry(first, { + text: "Same text", + snippet: "Same text", + createdAt: 2, + source: "click", + mode: "copy", + url: "https://example.com" + }, 20); + + assert.equal(second.length, 1, "Should have only 1 entry after copying same text twice"); + assert.equal(second[0].createdAt, 2, "Should update timestamp to latest"); +}); + +test("pushHistoryEntry preserves pinned status on dedup", function () { + const entries = [{ + id: "pinned-1", + text: "Pinned entry", + snippet: "Pinned entry", + createdAt: 1, + source: "click", + mode: "copy", + hostname: "example.com", + pinned: true + }]; + + const result = utils.pushHistoryEntry(entries, { + text: "Pinned entry", + snippet: "Pinned entry", + createdAt: 2, + source: "shortcut", + mode: "copy", + hostname: "example.com", + pinned: false + }, 20); + + assert.equal(result.length, 1); + assert.equal(result[0].pinned, true, "Pinned status should be preserved from original"); + assert.equal(result[0].source, "shortcut", "Source should update to latest"); +}); From c911c731da9170a472452dfd6f413ea909417a30 Mon Sep 17 00:00:00 2001 From: VET Date: Tue, 19 May 2026 08:59:07 +0700 Subject: [PATCH 02/12] feat(extension): Freeze chrome-only hardening baseline before modularization Capture the current working tree as a stable baseline before the source moves into src modules and the release pipeline expands. This preserves the current Chrome-only runtime hardening and build contract so later refactors can be compared against a known-good state. Constraint: The working tree is the source of truth and must be preserved before deep refactors Rejected: Refactor in-place without a baseline commit | would make regression isolation harder Confidence: high Scope-risk: moderate Reversibility: clean Directive: Use this commit as the comparison point for any behavior regressions during modularization Tested: npm run validate; npm run pack:chrome; fixture HTTP 200 checks Not-tested: Full manual Chrome extension walkthrough against the checklist Co-Authored-By: Claude --- .github/workflows/chrome-ci.yml | 29 +++ .gitignore | 2 + README.md | 42 ++-- background.js | 153 +++++++++----- docs/browser-manual-test-checklist.md | 15 +- manifest.json | 15 -- menu.js | 214 ++++++++++++++++--- options.js | 79 +++++-- package.json | 7 +- popup.js | 75 +++++-- scripts/build-chrome.cjs | 49 +++++ scripts/check-manifest.cjs | 31 +++ scripts/check-syntax.cjs | 58 ++++++ scripts/lib/release-utils.cjs | 288 ++++++++++++++++++++++++++ scripts/pack-chrome.cjs | 35 ++++ shared.js | 115 ++++++++++ tests/release-utils.test.js | 71 +++++++ tests/shared.test.js | 32 +++ 18 files changed, 1153 insertions(+), 157 deletions(-) create mode 100644 .github/workflows/chrome-ci.yml create mode 100644 scripts/build-chrome.cjs create mode 100644 scripts/check-manifest.cjs create mode 100644 scripts/check-syntax.cjs create mode 100644 scripts/lib/release-utils.cjs create mode 100644 scripts/pack-chrome.cjs create mode 100644 tests/release-utils.test.js diff --git a/.github/workflows/chrome-ci.yml b/.github/workflows/chrome-ci.yml new file mode 100644 index 0000000..59cfe0a --- /dev/null +++ b/.github/workflows/chrome-ci.yml @@ -0,0 +1,29 @@ +name: Chrome CI + +on: + push: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Run unit tests + run: npm test + + - name: Check syntax + run: npm run check:syntax + + - name: Check manifest + run: npm run check:manifest + + - name: Build Chrome package + run: npm run build:chrome diff --git a/.gitignore b/.gitignore index 35697e6..3f763e5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ Temporary Items .apdisk web-ext-artifacts +dist/ +.omx/ # AI Context .cobridge diff --git a/README.md b/README.md index 758c7c8..460a32d 100644 --- a/README.md +++ b/README.md @@ -19,31 +19,30 @@ Easy and fast copy tool. ## Download -* [Add-ons for Firefox](https://addons.mozilla.org/ja/firefox/addon/copy-text-without-selecting/ "Copy Text with Alt-Click :: Add-ons for Firefox") -* [Chrome Web Store](https://chrome.google.com/webstore/detail/copy-text-with-alt-click/obhagoegpnbklgknnmbglghkfdidegkl?authuser=0&hl=en "Copy text with Alt-Click - Chrome Web Store") +- [Chrome Web Store](https://chrome.google.com/webstore/detail/copy-text-with-alt-click/obhagoegpnbklgknnmbglghkfdidegkl?authuser=0&hl=en "Copy text with Alt-Click - Chrome Web Store") ## Description -If you install this add-on, you do not need to select a text. -What is needed, just "Alt key & Click" on the text! That's all! +If you install this extension, you do not need to select text first. +Just hold the configured modifier and click the target text. -This alone, the text of the point you click will be copied. +The clicked text is copied immediately. ## How It Works -- `background.js` dynamically registers the content script and respects excluded domains -- `menu.js` handles hover preview, smart extraction, copy, native-copy tracking, and visual feedback -- `popup.html` / `popup.js` provide quick controls for the current site and common settings -- `options.html` / `options.js` expose the full tabbed settings management experience -- `shared.js` contains reusable settings and hostname utilities shared by runtime, popup, options, and tests -- local history and analytics are stored in `chrome.storage.local` +- `background.js` manages MV3 startup, content-script registration, shortcut handling, and storage cleanup +- `menu.js` handles hover preview, extraction, copy behavior, history, analytics, and runtime invalidation safety in page context +- `popup.html` / `popup.js` provide quick controls for the current site and the most common settings +- `options.html` / `options.js` expose the full tabbed settings and history management UI +- `shared.js` contains shared settings, history, analytics, and runtime-safe Chrome API helpers +- Local history and analytics are stored in `chrome.storage.local` ## Settings - **Copy modifier**: `Alt`, `Ctrl`, or `Shift` - **Hover preview**: toggles the dashed target overlay - **Skip editable apps**: avoids contenteditable editors and rich text surfaces -- **Feedback duration**: controls how long the copy feedback remains visible, with fully custom timing +- **Feedback duration**: controls how long copy feedback remains visible - **Excluded domains**: one hostname per line, matched against the host and its subdomains - **Extension language**: `Auto`, `English`, or `Tiếng Việt` - **Copy history size**: controls how many recent copied items remain available @@ -58,6 +57,8 @@ The repo includes browser fixtures and a manual checklist: - `fixtures/basic-copy.html` - `fixtures/editable-surfaces.html` - `fixtures/keyboard-shortcut.html` +- `fixtures/selection-copy.html` +- `fixtures/table-copy.html` - `docs/browser-manual-test-checklist.md` Serve the fixtures over HTTP before testing: @@ -66,15 +67,26 @@ Serve the fixtures over HTTP before testing: python -m http.server 4173 ``` +## Development Target + +- The repository is maintained for **Chrome MV3** +- `manifest.json` is the only supported shipping manifest + ## Development -Run the repository tests with Node.js: +Run the repository checks with Node.js: + +```bash +npm run validate +``` + +Create a release zip: ```bash -npm test +npm run pack:chrome ``` -The test suite uses the built-in `node:test` runner, so there are no external development dependencies to install. +The build output is written to `dist/chrome/` and the packaged archive is written to `dist/`. ![Screenshot](https://addons.mozilla.org/user-media/previews/full/193/193185.png?modified=1622132342) diff --git a/background.js b/background.js index 704c2d3..1299de1 100644 --- a/background.js +++ b/background.js @@ -4,26 +4,60 @@ if (typeof importScripts === "function") { var utils = globalThis.CopyTextUtils; var CONTENT_SCRIPT_ID = "copy-text-with-alt-click-content"; +var HISTORY_CLEANUP_ALARM = "copy-text-history-cleanup"; + +if (!utils) { + console.error("CopyTextUtils is not available."); +} else { + utils.addListenerSafely(chrome.runtime.onInstalled, function () { + initializeExtension().catch(function (error) { + reportBackgroundError("Initializing extension on install failed.", error); + }); + }); -chrome.runtime.onInstalled.addListener(function () { - initializeExtension(); -}); + utils.addListenerSafely(chrome.runtime.onStartup, function () { + initializeExtension().catch(function (error) { + reportBackgroundError("Initializing extension on startup failed.", error); + }); + }); -chrome.runtime.onStartup.addListener(function () { - initializeExtension(); -}); + utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { + if (!utils.isExtensionContextValid() || areaName != "sync") { + return; + } -chrome.storage.onChanged.addListener(function (changes, areaName) { - if (areaName == "sync") { - initializeExtension(); - } -}); + initializeExtension().catch(function (error) { + reportBackgroundError("Re-initializing extension after settings change failed.", error); + }); + }); -chrome.commands.onCommand.addListener(function (command) { - triggerShortcutCopy(command); -}); + utils.addListenerSafely(chrome.commands.onCommand, function (command) { + triggerShortcutCopy(command).catch(function (error) { + reportBackgroundError("Handling shortcut command failed.", error); + }); + }); + + try { + chrome.alarms.create(HISTORY_CLEANUP_ALARM, { periodInMinutes: 360 }); + utils.addListenerSafely(chrome.alarms.onAlarm, function (alarm) { + if (!alarm || alarm.name != HISTORY_CLEANUP_ALARM) { + return; + } + + initializeHistoryCleanup().catch(function (error) { + reportBackgroundError("Running history cleanup failed.", error); + }); + }); + } catch (error) { + reportBackgroundError("Scheduling history cleanup failed.", error); + } +} async function initializeExtension() { + if (!utils.isExtensionContextValid()) { + return; + } + var settings = await ensureSettings(); await syncContentScriptRegistration(settings); await injectContentScriptsIntoOpenTabs(settings); @@ -31,31 +65,39 @@ async function initializeExtension() { } async function ensureSettings() { - var current = await chrome.storage.sync.get(utils.DEFAULT_SETTINGS); + var current = await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS); var merged = utils.mergeSettings(current); - await chrome.storage.sync.set(merged); + await utils.safeStorageSet("sync", merged); return merged; } async function syncContentScriptRegistration(settings) { + if (!utils.isExtensionContextValid()) { + return; + } + try { - await chrome.scripting.unregisterContentScripts({ ids: [CONTENT_SCRIPT_ID] }); + await utils.safeChromeAsync(function () { + return chrome.scripting.unregisterContentScripts({ ids: [CONTENT_SCRIPT_ID] }); + }, true); } catch (error) { - // Ignore when the content script was not registered yet. + reportBackgroundError("Unregistering prior content scripts failed.", error); } - await chrome.scripting.registerContentScripts([{ - id: CONTENT_SCRIPT_ID, - matches: ["http://*/*", "https://*/*"], - excludeMatches: utils.buildExcludeMatches(settings.excludedDomains), - js: ["shared.js", "menu.js"], - runAt: "document_start", - persistAcrossSessions: true, - }]); + await utils.safeChromeAsync(function () { + return chrome.scripting.registerContentScripts([{ + id: CONTENT_SCRIPT_ID, + matches: ["http://*/*", "https://*/*"], + excludeMatches: utils.buildExcludeMatches(settings.excludedDomains), + js: ["shared.js", "menu.js"], + runAt: "document_start", + persistAcrossSessions: true, + }]); + }, false); } async function injectContentScriptsIntoOpenTabs(settings) { - var tabs = await chrome.tabs.query({}); + var tabs = await utils.safeTabsQuery({}); await Promise.all(tabs.map(async function (tab) { if (!tab.id || !tab.url) { @@ -68,27 +110,27 @@ async function injectContentScriptsIntoOpenTabs(settings) { } try { - await chrome.scripting.executeScript({ + await utils.safeExecuteScript({ target: { tabId: tab.id }, files: ["shared.js", "menu.js"], }); } catch (error) { - // Ignore tabs where scripting is not allowed. + reportBackgroundError("Injecting content scripts into an open tab failed.", error); } })); } async function triggerShortcutCopy(command) { - var settings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); - if (!settings.keyboardShortcutEnabled) { + if (command != "copy-focused-target") { return; } - if (command != "copy-focused-target") { + var settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); + if (!settings.keyboardShortcutEnabled) { return; } - var tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + var tabs = await utils.safeTabsQuery({ active: true, lastFocusedWindow: true }); var activeTab = tabs[0]; if (!activeTab || !activeTab.id || !activeTab.url) { return; @@ -111,8 +153,12 @@ async function triggerShortcutCopy(command) { type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", }); } catch (error) { + if (utils.isExtensionContextInvalidatedError(error)) { + return; + } + try { - await chrome.scripting.executeScript({ + await utils.safeExecuteScript({ target: { tabId: activeTab.id }, files: ["shared.js", "menu.js"], }); @@ -121,25 +167,24 @@ async function triggerShortcutCopy(command) { type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", }); } catch (secondError) { - // Ignore when the page does not allow messaging or injection. + if (utils.isExtensionContextInvalidatedError(secondError)) { + return; + } } } } async function trimHistory(limit) { - var current = await chrome.storage.local.get({ copyHistory: [] }); + var current = await utils.safeStorageGet("local", { copyHistory: [] }); var history = Array.isArray(current.copyHistory) ? current.copyHistory : []; - // Enforce entry count limit if (history.length > limit) { history = history.slice(0, limit); } - // Enforce storage size guard (keep under 4MB to leave room for analytics/other data) var maxBytes = 4 * 1024 * 1024; var serialized = JSON.stringify(history); while (serialized.length > maxBytes && history.length > 1) { - // Remove the oldest non-pinned entry first var indexToRemove = -1; for (var i = history.length - 1; i >= 0; i--) { if (!history[i].pinned) { @@ -148,34 +193,30 @@ async function trimHistory(limit) { } } if (indexToRemove === -1) { - // All pinned, remove last one anyway indexToRemove = history.length - 1; } history.splice(indexToRemove, 1); serialized = JSON.stringify(history); } - await chrome.storage.local.set({ copyHistory: history }); + await utils.safeStorageSet("local", { copyHistory: history }); } -// Schedule periodic history cleanup every 6 hours -try { - chrome.alarms.create("copy-text-history-cleanup", { periodInMinutes: 360 }); - chrome.alarms.onAlarm.addListener(function (alarm) { - if (alarm.name === "copy-text-history-cleanup") { - chrome.storage.sync.get(utils.DEFAULT_SETTINGS, function (items) { - var settings = utils.mergeSettings(items); - trimHistory(settings.copyHistoryLimit); - }); - } - }); -} catch (error) { - // Alarms API may not be available in all contexts +async function initializeHistoryCleanup() { + var settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); + await trimHistory(settings.copyHistoryLimit); } async function saveAnalyticsEvent(event) { - var current = await chrome.storage.local.get({ copyAnalytics: utils.DEFAULT_ANALYTICS }); + var current = await utils.safeStorageGet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); var nextAnalytics = utils.recordAnalyticsEvent(current.copyAnalytics, event || {}); - await chrome.storage.local.set({ copyAnalytics: nextAnalytics }); + await utils.safeStorageSet("local", { copyAnalytics: nextAnalytics }); } +function reportBackgroundError(message, error) { + if (utils.isExtensionContextInvalidatedError(error)) { + return; + } + + console.warn(message, error); +} diff --git a/docs/browser-manual-test-checklist.md b/docs/browser-manual-test-checklist.md index c8eeb12..6cc01b9 100644 --- a/docs/browser-manual-test-checklist.md +++ b/docs/browser-manual-test-checklist.md @@ -2,15 +2,16 @@ ## Setup -1. Load the extension unpacked in Chrome or Edge. -2. Open `chrome://extensions/shortcuts` and confirm the extension commands are visible. -3. Serve the fixtures with a local HTTP server: +1. Run `npm run validate`. +2. Load the extension unpacked in Chrome. +3. Open `chrome://extensions/shortcuts` and confirm the extension commands are visible. +4. Serve the fixtures with a local HTTP server: ```bash python -m http.server 4173 ``` -4. Open: +5. Open: - `http://localhost:4173/fixtures/basic-copy.html` - `http://localhost:4173/fixtures/editable-surfaces.html` - `http://localhost:4173/fixtures/keyboard-shortcut.html` @@ -89,6 +90,12 @@ python -m http.server 4173 - [ ] Shortcut copies focused element when nothing is hovered - [ ] Disabling keyboard shortcut mode in settings suppresses command behavior +## Reload / Update + +- [ ] Reloading the extension does not cause repeated `Extension context invalidated` console spam on refreshed pages +- [ ] After extension reload + page refresh, copy still works on all fixture pages +- [ ] Popup, history, and shortcut behavior still work after extension reload + ## History - [ ] New copy actions create local history entries diff --git a/manifest.json b/manifest.json index ee94196..324804d 100644 --- a/manifest.json +++ b/manifest.json @@ -5,17 +5,6 @@ "description": "Copy text with Alt-Click. Easy and fast copy tool.", "default_locale": "en", - "browser_specific_settings": { - "gecko": { - "id": "@copy-text-without-selecting", - "data_collection_permissions": { - "required": [ - "none" - ] - } - } - }, - "permissions" : [ "activeTab", "scripting", @@ -33,10 +22,6 @@ }, "background": { - "scripts": [ - "shared.js", - "background.js" - ], "service_worker": "background.js" }, "action": { diff --git a/menu.js b/menu.js index b4e5666..40b6765 100644 --- a/menu.js +++ b/menu.js @@ -1,10 +1,9 @@ (function () { - if (globalThis.__copyTextWithAltClickContentScriptLoaded) { - return; + var previousState = globalThis.__copyTextWithAltClickContentScriptState; + if (previousState && typeof previousState.dispose === "function") { + previousState.dispose(); } - globalThis.__copyTextWithAltClickContentScriptLoaded = true; - var utils = globalThis.CopyTextUtils; if (!utils) { console.error("CopyTextUtils is not available."); @@ -27,16 +26,31 @@ lastRenderedTarget: null, }; var suppressNativeCopyTracking = false; + var extensionContextInvalidated = !utils.isExtensionContextValid(); + var disposed = false; + var scriptState = { dispose: dispose }; + + globalThis.__copyTextWithAltClickContentScriptState = scriptState; updateSettings(); + attachExtensionListeners(); + attachDomListeners(); + + function onStorageChanged(changes, areaName) { + if (!isExtensionUsable()) { + return; + } - chrome.storage.onChanged.addListener(function (changes, areaName) { if (areaName == "sync") { updateSettings(); } - }); + } + + function onRuntimeMessage(message) { + if (!isExtensionUsable()) { + return; + } - chrome.runtime.onMessage.addListener(function (message) { if (!message || message.type != "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT") { return; } @@ -75,23 +89,113 @@ copyCommand(shortcutTarget, "shortcut", { preferSelection: true, }).catch(function (error) { + if (handleExtensionContextError(error)) { + return; + } console.error("Shortcut copy failed.", error); }); - }); - - document.addEventListener("click", handleClick, false); - document.addEventListener("mousemove", handleMouseMove, true); - document.addEventListener("mouseover", handleMouseOver, true); - document.addEventListener("mouseout", handleMouseOut, true); - document.addEventListener("copy", handleNativeCopy, true); - document.addEventListener("keydown", handleModifierChange, true); - document.addEventListener("keyup", handleModifierChange, true); - document.addEventListener("wheel", handleWheel, { capture: true, passive: false }); - document.addEventListener("scroll", handleViewportChange, true); - document.addEventListener("visibilitychange", handleVisibilityChange, true); - window.addEventListener("resize", handleViewportChange); + } + + function attachExtensionListeners() { + if (extensionContextInvalidated) { + return; + } + + try { + utils.addListenerSafely(chrome.storage.onChanged, onStorageChanged); + } catch (error) { + handleExtensionContextError(error); + } + + try { + utils.addListenerSafely(chrome.runtime.onMessage, onRuntimeMessage); + } catch (error) { + handleExtensionContextError(error); + } + } + + function removeExtensionListeners() { + try { + utils.removeListenerSafely(chrome.storage.onChanged, onStorageChanged); + } catch (error) { + // Ignore invalidated contexts during teardown. + } + + try { + utils.removeListenerSafely(chrome.runtime.onMessage, onRuntimeMessage); + } catch (error) { + // Ignore invalidated contexts during teardown. + } + } + + function attachDomListeners() { + document.addEventListener("click", handleClick, false); + document.addEventListener("mousemove", handleMouseMove, true); + document.addEventListener("mouseover", handleMouseOver, true); + document.addEventListener("mouseout", handleMouseOut, true); + document.addEventListener("copy", handleNativeCopy, true); + document.addEventListener("keydown", handleModifierChange, true); + document.addEventListener("keyup", handleModifierChange, true); + document.addEventListener("wheel", handleWheel, { capture: true, passive: false }); + document.addEventListener("scroll", handleViewportChange, true); + document.addEventListener("visibilitychange", handleVisibilityChange, true); + window.addEventListener("resize", handleViewportChange); + } + + function removeDomListeners() { + document.removeEventListener("click", handleClick, false); + document.removeEventListener("mousemove", handleMouseMove, true); + document.removeEventListener("mouseover", handleMouseOver, true); + document.removeEventListener("mouseout", handleMouseOut, true); + document.removeEventListener("copy", handleNativeCopy, true); + document.removeEventListener("keydown", handleModifierChange, true); + document.removeEventListener("keyup", handleModifierChange, true); + document.removeEventListener("wheel", handleWheel, true); + document.removeEventListener("scroll", handleViewportChange, true); + document.removeEventListener("visibilitychange", handleVisibilityChange, true); + window.removeEventListener("resize", handleViewportChange); + } + + function isExtensionUsable() { + return !disposed && !extensionContextInvalidated && utils.isExtensionContextValid(); + } + + function handleExtensionContextError(error) { + if (!utils.isExtensionContextInvalidatedError(error)) { + return false; + } + + dispose(); + return true; + } + + function dispose() { + if (disposed) { + return; + } + + disposed = true; + extensionContextInvalidated = true; + + if (hoverState.previewAnimationFrame) { + cancelAnimationFrame(hoverState.previewAnimationFrame); + hoverState.previewAnimationFrame = 0; + } + + removeExtensionListeners(); + removeDomListeners(); + hidePreview(); + + if (globalThis.__copyTextWithAltClickContentScriptState === scriptState) { + delete globalThis.__copyTextWithAltClickContentScriptState; + } + } function handleClick(event) { + if (!isExtensionUsable()) { + return; + } + var copyMode = utils.getCopyMode(settings.metaKey, event); if (!copyMode || isCurrentHostExcluded()) { if (copyMode && isCurrentHostExcluded()) { @@ -117,6 +221,9 @@ if (targetToCopy) { executePrecisionCopy(targetToCopy, "click").catch(function (error) { + if (handleExtensionContextError(error)) { + return; + } console.error("Copy failed.", error); }); } else { @@ -127,6 +234,9 @@ clientY: event.clientY, preferSelection: true, }).catch(function (error) { + if (handleExtensionContextError(error)) { + return; + } console.error("Copy failed.", error); }); } @@ -135,6 +245,10 @@ } function handleMouseMove(event) { + if (!isExtensionUsable()) { + return; + } + // Fast path: only track pointer coordinates when no modifier is held if (!utils.isPrimaryModifierPressed(settings.metaKey, event)) { hoverState.pointerClientX = event.clientX; @@ -159,6 +273,10 @@ } function handleMouseOver(event) { + if (!isExtensionUsable()) { + return; + } + syncPointerState(event); hoverState.hoveredElement = getElementNode(event.target); hoverState.previewModifierActive = utils.isPrimaryModifierPressed(settings.metaKey, event); @@ -169,6 +287,10 @@ } function handleMouseOut(event) { + if (!isExtensionUsable()) { + return; + } + if (!event.relatedTarget) { hoverState.hoveredElement = null; hidePreview(); @@ -185,6 +307,10 @@ } function handleNativeCopy() { + if (!isExtensionUsable()) { + return; + } + if (suppressNativeCopyTracking || isCurrentHostExcluded()) { return; } @@ -202,6 +328,10 @@ } function handleModifierChange(event) { + if (!isExtensionUsable()) { + return; + } + if (!utils.isModifierKeyEvent(event)) { return; } @@ -225,18 +355,30 @@ } function handleViewportChange() { + if (!isExtensionUsable()) { + return; + } + if (shouldShowPreview()) { schedulePreviewUpdate(); } } function handleVisibilityChange() { + if (!isExtensionUsable()) { + return; + } + if (document.hidden) { hidePreview(); } } function handleWheel(event) { + if (!isExtensionUsable()) { + return; + } + if (!hoverState.previewModifierActive || !shouldShowPreview()) { return; } @@ -375,7 +517,15 @@ } function updateSettings() { - chrome.storage.sync.get(utils.DEFAULT_SETTINGS, function (items) { + if (!isExtensionUsable()) { + return; + } + + utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS).then(function (items) { + if (!isExtensionUsable()) { + return; + } + settings = utils.mergeSettings(items); if (isCurrentHostExcluded() || !hoverState.previewModifierActive) { @@ -383,6 +533,12 @@ } else if (shouldShowPreview()) { schedulePreviewUpdate(); } + }).catch(function (error) { + if (handleExtensionContextError(error)) { + return; + } + + console.warn("Updating settings failed.", error); }); } @@ -1216,7 +1372,7 @@ } try { - var current = await chrome.storage.local.get({ copyHistory: [] }); + var current = await utils.safeStorageGet("local", { copyHistory: [] }); var nextHistory = utils.pushHistoryEntry(current.copyHistory, { text: text, snippet: utils.getTextSnippet(text), @@ -1229,8 +1385,12 @@ lastReplayedAt: isSelectionBased ? Date.now() : 0, }, settings.copyHistoryLimit); - await chrome.storage.local.set({ copyHistory: nextHistory }); + await utils.safeStorageSet("local", { copyHistory: nextHistory }); } catch (error) { + if (handleExtensionContextError(error)) { + return; + } + console.warn("Saving copy history failed.", error); } } @@ -1241,13 +1401,17 @@ async function saveAnalyticsEvents(events) { try { - var current = await chrome.storage.local.get({ copyAnalytics: utils.DEFAULT_ANALYTICS }); + var current = await utils.safeStorageGet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); var nextAnalytics = current.copyAnalytics; (Array.isArray(events) ? events : [events]).forEach(function (event) { nextAnalytics = utils.recordAnalyticsEvent(nextAnalytics, event || {}); }); - await chrome.storage.local.set({ copyAnalytics: nextAnalytics }); + await utils.safeStorageSet("local", { copyAnalytics: nextAnalytics }); } catch (error) { + if (handleExtensionContextError(error)) { + return; + } + console.warn("Saving copy analytics failed.", error); } } diff --git a/options.js b/options.js index 2fff111..641b6bc 100644 --- a/options.js +++ b/options.js @@ -18,14 +18,37 @@ return; } + function isExtensionUsable() { + return utils.isExtensionContextValid(); + } + + function reportOptionsError(message, error) { + if (utils.isExtensionContextInvalidatedError(error)) { + return true; + } + + console.warn(message, error); + return false; + } + document.addEventListener("DOMContentLoaded", initializeOptions); async function initializeOptions() { - await restoreOptions(); - applyMessages(); - bindEvents(); - await renderExcludedDomains(); - await renderHistoryAndAnalytics(); + if (!isExtensionUsable()) { + return; + } + + try { + await restoreOptions(); + applyMessages(); + bindEvents(); + await renderExcludedDomains(); + await renderHistoryAndAnalytics(); + } catch (error) { + if (reportOptionsError("Initializing the options page failed.", error)) { + return; + } + } } function bindEvents() { @@ -109,17 +132,25 @@ document.getElementById("clear_history_button").addEventListener("click", clearHistory); document.getElementById("reset_analytics_button").addEventListener("click", resetAnalytics); - chrome.storage.onChanged.addListener(function (changes, areaName) { + utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { + if (!isExtensionUsable()) { + return; + } + if (areaName == "sync") { restoreOptions().then(function () { applyMessages(); renderExcludedDomains(); renderHistoryAndAnalytics(); + }).catch(function (error) { + reportOptionsError("Refreshing the options page after settings change failed.", error); }); } if (areaName == "local" && (changes.copyHistory || changes.copyAnalytics)) { - renderHistoryAndAnalytics(); + renderHistoryAndAnalytics().catch(function (error) { + reportOptionsError("Refreshing options history or analytics failed.", error); + }); } }); } @@ -132,7 +163,7 @@ } async function restoreOptions() { - settings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); + settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); document.getElementById("meta_key").value = settings.metaKey; document.getElementById("preview_enabled").checked = settings.previewEnabled; @@ -148,6 +179,10 @@ } async function saveOptions() { + if (!isExtensionUsable()) { + return; + } + settings = utils.mergeSettings({ metaKey: document.getElementById("meta_key").value, previewEnabled: document.getElementById("preview_enabled").checked, @@ -159,7 +194,7 @@ excludedDomains: document.getElementById("excluded_domains_bulk").value, }); - await chrome.storage.sync.set(settings); + await utils.safeStorageSet("sync", settings); showStatus(t("save_status_saved", "Saved")); await renderExcludedDomains(); } @@ -181,7 +216,7 @@ document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); input.value = ""; - await chrome.storage.sync.set(settings); + await utils.safeStorageSet("sync", settings); await renderExcludedDomains(); showStatus(t("save_status_saved", "Saved")); } @@ -190,7 +225,7 @@ settings.excludedDomains = utils.normalizeExcludedDomains(document.getElementById("excluded_domains_bulk").value); document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); - await chrome.storage.sync.set(settings); + await utils.safeStorageSet("sync", settings); await renderExcludedDomains(); showStatus(t("save_status_saved", "Saved")); } @@ -225,7 +260,7 @@ remove.addEventListener("click", async function () { settings.excludedDomains = utils.toggleDomain(settings.excludedDomains, domain); document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); - await chrome.storage.sync.set(settings); + await utils.safeStorageSet("sync", settings); await renderExcludedDomains(); showStatus(t("save_status_saved", "Saved")); }); @@ -238,7 +273,7 @@ } async function renderHistoryAndAnalytics() { - var current = await chrome.storage.local.get({ + var current = await utils.safeStorageGet("local", { copyHistory: [], copyAnalytics: utils.DEFAULT_ANALYTICS, }); @@ -541,16 +576,16 @@ } async function deleteHistoryItem(historyId) { - var current = await chrome.storage.local.get({ copyHistory: [] }); + var current = await utils.safeStorageGet("local", { copyHistory: [] }); var nextHistory = utils.deleteHistoryEntries(current.copyHistory, [historyId]); selectedHistoryIds.delete(historyId); - await chrome.storage.local.set({ copyHistory: nextHistory }); + await utils.safeStorageSet("local", { copyHistory: nextHistory }); await renderHistoryAndAnalytics(); showStatus(t("history_deleted", "History item deleted")); } async function recordReplayUsage(item, analyticsType) { - var current = await chrome.storage.local.get({ + var current = await utils.safeStorageGet("local", { copyHistory: [], copyAnalytics: utils.DEFAULT_ANALYTICS, }); @@ -569,14 +604,14 @@ toastKind: "status", }); - await chrome.storage.local.set({ + await utils.safeStorageSet("local", { copyHistory: nextHistory, copyAnalytics: nextAnalytics, }); } async function clearHistory() { - await chrome.storage.local.set({ copyHistory: [] }); + await utils.safeStorageSet("local", { copyHistory: [] }); selectedHistoryIds.clear(); bulkSelectionMode = false; await renderHistoryAndAnalytics(); @@ -584,7 +619,7 @@ } async function resetAnalytics() { - await chrome.storage.local.set({ copyAnalytics: utils.DEFAULT_ANALYTICS }); + await utils.safeStorageSet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); await renderHistoryAndAnalytics(); showStatus(t("analytics_reset_done", "Analytics reset")); } @@ -689,9 +724,9 @@ if (!selectedHistoryIds.size) { return; } - var current = await chrome.storage.local.get({ copyHistory: [] }); + var current = await utils.safeStorageGet("local", { copyHistory: [] }); var nextHistory = utils.deleteHistoryEntries(current.copyHistory, Array.from(selectedHistoryIds)); - await chrome.storage.local.set({ copyHistory: nextHistory }); + await utils.safeStorageSet("local", { copyHistory: nextHistory }); selectedHistoryIds.clear(); bulkSelectionMode = false; await renderHistoryAndAnalytics(); @@ -703,7 +738,7 @@ return; } - var current = await chrome.storage.local.get({ copyHistory: [] }); + var current = await utils.safeStorageGet("local", { copyHistory: [] }); var selectedItems = (Array.isArray(current.copyHistory) ? current.copyHistory : []).filter(function (item) { return item && selectedHistoryIds.has(item.id); }); diff --git a/package.json b/package.json index 05b48f4..5307b1b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,11 @@ "private": true, "description": "Manifest V3 browser extension for copying text with modifier-assisted clicks.", "scripts": { - "test": "node --test" + "test": "node --test", + "check:syntax": "node scripts/check-syntax.cjs", + "check:manifest": "node scripts/check-manifest.cjs", + "validate": "npm run test && npm run check:syntax && npm run check:manifest && npm run build:chrome", + "build:chrome": "node scripts/build-chrome.cjs", + "pack:chrome": "node scripts/pack-chrome.cjs" } } diff --git a/popup.js b/popup.js index 374f78b..211b6f7 100644 --- a/popup.js +++ b/popup.js @@ -8,6 +8,19 @@ return; } + function isExtensionUsable() { + return utils.isExtensionContextValid(); + } + + function reportPopupError(message, error) { + if (utils.isExtensionContextInvalidatedError(error)) { + return true; + } + + console.warn(message, error); + return false; + } + var elements = { site: document.getElementById("current_site"), siteStatus: document.getElementById("current_site_status"), @@ -26,9 +39,19 @@ document.addEventListener("DOMContentLoaded", initializePopup); async function initializePopup() { - await restoreSettings(); - applyMessages(); - await Promise.all([detectCurrentSite(), renderHistory()]); + if (!isExtensionUsable()) { + return; + } + + try { + await restoreSettings(); + applyMessages(); + await Promise.all([detectCurrentSite(), renderHistory()]); + } catch (error) { + if (reportPopupError("Initializing popup failed.", error)) { + return; + } + } elements.metaKey.addEventListener("change", saveSettings); elements.previewEnabled.addEventListener("change", saveSettings); @@ -40,20 +63,30 @@ elements.toggleSite.addEventListener("click", toggleCurrentSite); elements.clearHistory.addEventListener("click", clearHistory); elements.openOptions.addEventListener("click", function () { - chrome.runtime.openOptionsPage(); + utils.safeOpenOptionsPage().catch(function (error) { + reportPopupError("Opening the options page failed.", error); + }); }); - chrome.storage.onChanged.addListener(function (changes, areaName) { + utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { + if (!isExtensionUsable()) { + return; + } + if (areaName == "sync") { restoreSettings().then(function () { applyMessages(); refreshSiteState(); renderHistory(); + }).catch(function (error) { + reportPopupError("Refreshing popup after settings change failed.", error); }); } if (areaName == "local" && changes.copyHistory) { - renderHistory(); + renderHistory().catch(function (error) { + reportPopupError("Refreshing popup history failed.", error); + }); } }); } @@ -77,7 +110,7 @@ } async function restoreSettings() { - settings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); + settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); elements.metaKey.value = settings.metaKey; elements.previewEnabled.checked = settings.previewEnabled; elements.avoidEditable.checked = settings.avoidEditable; @@ -88,7 +121,7 @@ } async function detectCurrentSite() { - var tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + var tabs = await utils.safeTabsQuery({ active: true, lastFocusedWindow: true }); var activeTab = tabs[0]; currentHostname = activeTab ? utils.getHostnameFromUrl(activeTab.url) : ""; @@ -105,7 +138,7 @@ } async function refreshSiteState() { - var currentSettings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); + var currentSettings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); var isExcluded = utils.isExcludedHost(currentHostname, currentSettings.excludedDomains); setSiteStatus(isExcluded ? "excluded" : "active"); @@ -131,6 +164,10 @@ } async function saveSettings() { + if (!isExtensionUsable()) { + return; + } + settings = utils.mergeSettings(Object.assign({}, settings, { metaKey: elements.metaKey.value, previewEnabled: elements.previewEnabled.checked, @@ -139,7 +176,7 @@ copyHistoryLimit: elements.copyHistoryLimit ? elements.copyHistoryLimit.value : settings.copyHistoryLimit, })); - await chrome.storage.sync.set(settings); + await utils.safeStorageSet("sync", settings); showStatus(t("save_status_saved", "Saved")); } @@ -148,16 +185,16 @@ return; } - var currentSettings = utils.mergeSettings(await chrome.storage.sync.get(utils.DEFAULT_SETTINGS)); + var currentSettings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); currentSettings.excludedDomains = utils.toggleDomain(currentSettings.excludedDomains, currentHostname); - await chrome.storage.sync.set(currentSettings); + await utils.safeStorageSet("sync", currentSettings); await refreshSiteState(); showStatus(t("save_status_saved", "Saved")); } async function renderHistory() { - var current = await chrome.storage.local.get({ copyHistory: [] }); + var current = await utils.safeStorageGet("local", { copyHistory: [] }); var history = Array.isArray(current.copyHistory) ? current.copyHistory : []; elements.historyList.textContent = ""; @@ -167,7 +204,7 @@ var icon = document.createElement("div"); icon.className = "empty-state-icon"; - icon.textContent = "📋"; + icon.textContent = "\uD83D\uDCCB"; var text = document.createElement("div"); text.className = "empty-state-text"; @@ -323,7 +360,7 @@ } async function recordReplayUsage(item, analyticsType) { - var current = await chrome.storage.local.get({ + var current = await utils.safeStorageGet("local", { copyHistory: [], copyAnalytics: utils.DEFAULT_ANALYTICS, }); @@ -342,22 +379,22 @@ toastKind: "status", }); - await chrome.storage.local.set({ + await utils.safeStorageSet("local", { copyHistory: nextHistory, copyAnalytics: nextAnalytics, }); } async function clearHistory() { - await chrome.storage.local.set({ copyHistory: [] }); + await utils.safeStorageSet("local", { copyHistory: [] }); await renderHistory(); showStatus(t("copy_history_cleared", "History cleared")); } async function deleteHistoryItem(historyId) { - var current = await chrome.storage.local.get({ copyHistory: [] }); + var current = await utils.safeStorageGet("local", { copyHistory: [] }); var nextHistory = utils.deleteHistoryEntries(current.copyHistory, [historyId]); - await chrome.storage.local.set({ copyHistory: nextHistory }); + await utils.safeStorageSet("local", { copyHistory: nextHistory }); await renderHistory(); showStatus(t("history_deleted", "History item deleted")); } diff --git a/scripts/build-chrome.cjs b/scripts/build-chrome.cjs new file mode 100644 index 0000000..00f94b4 --- /dev/null +++ b/scripts/build-chrome.cjs @@ -0,0 +1,49 @@ +const path = require("node:path"); +const { + CHROME_DIST_DIR, + PROJECT_ROOT, + copyReleaseFiles, + readJson, + validateManifestData, + verifyChromeBuildOutput, +} = require("./lib/release-utils.cjs"); + +function buildChrome() { + const manifest = readJson(path.join(PROJECT_ROOT, "manifest.json")); + const pkg = readJson(path.join(PROJECT_ROOT, "package.json")); + const errors = validateManifestData(manifest, pkg); + if (errors.length) { + throw new Error(errors.join("\n")); + } + + const expectedFiles = copyReleaseFiles(PROJECT_ROOT, CHROME_DIST_DIR); + const verification = verifyChromeBuildOutput(CHROME_DIST_DIR, expectedFiles); + if (verification.missing.length || verification.extras.length) { + const messages = []; + if (verification.missing.length) { + messages.push("Missing files: " + verification.missing.join(", ")); + } + if (verification.extras.length) { + messages.push("Unexpected files: " + verification.extras.join(", ")); + } + throw new Error(messages.join("\n")); + } + + console.log(`Chrome build complete: ${expectedFiles.length} file(s) -> ${CHROME_DIST_DIR}`); + return { + outputDir: CHROME_DIST_DIR, + files: verification.actual, + }; +} + +if (require.main === module) { + try { + buildChrome(); + } catch (error) { + console.error("Chrome build failed."); + console.error(error.message || error); + process.exitCode = 1; + } +} + +module.exports = { buildChrome }; diff --git a/scripts/check-manifest.cjs b/scripts/check-manifest.cjs new file mode 100644 index 0000000..0a612ce --- /dev/null +++ b/scripts/check-manifest.cjs @@ -0,0 +1,31 @@ +const path = require("node:path"); +const { + PROJECT_ROOT, + readJson, + validateManifestData, +} = require("./lib/release-utils.cjs"); + +function main() { + const manifestPath = path.join(PROJECT_ROOT, "manifest.json"); + const packagePath = path.join(PROJECT_ROOT, "package.json"); + const manifest = readJson(manifestPath); + const pkg = readJson(packagePath); + const errors = validateManifestData(manifest, pkg); + + if (errors.length) { + console.error("Manifest validation failed:"); + for (const error of errors) { + console.error(" - " + error); + } + process.exitCode = 1; + return; + } + + console.log("Manifest validation passed."); +} + +if (require.main === module) { + main(); +} + +module.exports = { main }; diff --git a/scripts/check-syntax.cjs b/scripts/check-syntax.cjs new file mode 100644 index 0000000..c04e82b --- /dev/null +++ b/scripts/check-syntax.cjs @@ -0,0 +1,58 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { PROJECT_ROOT } = require("./lib/release-utils.cjs"); + +function listScriptFiles(rootDir) { + const files = []; + + function visit(currentDir) { + const children = fs.readdirSync(currentDir, { withFileTypes: true }).sort(function (left, right) { + return left.name.localeCompare(right.name); + }); + + for (const child of children) { + const absoluteChild = path.join(currentDir, child.name); + if (child.isDirectory()) { + visit(absoluteChild); + continue; + } + + if (absoluteChild.endsWith(".js") || absoluteChild.endsWith(".cjs")) { + files.push(absoluteChild); + } + } + } + + visit(rootDir); + return files; +} + +function main() { + const extensionFiles = [ + "background.js", + "menu.js", + "options.js", + "popup.js", + "shared.js", + ].map(function (fileName) { + return path.join(PROJECT_ROOT, fileName); + }); + + const scriptFiles = listScriptFiles(path.join(PROJECT_ROOT, "scripts")); + const filesToCheck = extensionFiles.concat(scriptFiles); + + for (const filePath of filesToCheck) { + execFileSync(process.execPath, ["--check", filePath], { + stdio: "pipe", + }); + } + + console.log(`Syntax check passed for ${filesToCheck.length} file(s).`); +} + +if (require.main === module) { + main(); +} + +module.exports = { main }; diff --git a/scripts/lib/release-utils.cjs b/scripts/lib/release-utils.cjs new file mode 100644 index 0000000..b91bb35 --- /dev/null +++ b/scripts/lib/release-utils.cjs @@ -0,0 +1,288 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const PROJECT_ROOT = path.resolve(__dirname, "..", ".."); +const DIST_ROOT = path.join(PROJECT_ROOT, "dist"); +const CHROME_DIST_DIR = path.join(DIST_ROOT, "chrome"); +const RELEASE_ARCHIVE_PREFIX = "copy-text-with-alt-click-chrome"; +const RELEASE_ROOT_ENTRIES = [ + "_locales", + "background.js", + "icon.png", + "LICENSE.txt", + "manifest.json", + "menu.js", + "options.css", + "options.html", + "options.js", + "popup.html", + "popup.js", + "shared.js", +]; + +function toPosixPath(value) { + return String(value).split(path.sep).join("/"); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function validateManifestData(manifest, pkg) { + const errors = []; + + if (!manifest || typeof manifest !== "object") { + return ["manifest.json is missing or invalid JSON."]; + } + + if (manifest.manifest_version !== 3) { + errors.push("manifest.json must use manifest_version 3."); + } + + if (pkg && manifest.version !== pkg.version) { + errors.push(`manifest.json version (${manifest.version}) must match package.json version (${pkg.version}).`); + } + + if (Object.prototype.hasOwnProperty.call(manifest, "browser_specific_settings")) { + errors.push("manifest.json must not contain browser_specific_settings in the Chrome-only release."); + } + + if (!manifest.background || typeof manifest.background !== "object") { + errors.push("manifest.json must define a background object."); + } else { + if (typeof manifest.background.service_worker !== "string" || !manifest.background.service_worker) { + errors.push("manifest.json background.service_worker must be a non-empty string."); + } + if (Object.prototype.hasOwnProperty.call(manifest.background, "scripts")) { + errors.push("manifest.json background.scripts must not be present in the Chrome-only release."); + } + if (Object.prototype.hasOwnProperty.call(manifest.background, "page")) { + errors.push("manifest.json background.page must not be present in the Chrome-only release."); + } + } + + if (!manifest.action || typeof manifest.action.default_popup !== "string" || !manifest.action.default_popup) { + errors.push("manifest.json action.default_popup must be a non-empty string."); + } + + if (!Array.isArray(manifest.permissions)) { + errors.push("manifest.json permissions must be an array."); + } + + if (!Array.isArray(manifest.host_permissions)) { + errors.push("manifest.json host_permissions must be an array."); + } + + return errors; +} + +function ensureCleanDir(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); + fs.mkdirSync(dirPath, { recursive: true }); +} + +function collectFileEntries(absolutePath, relativePath, entries) { + const stat = fs.statSync(absolutePath); + if (stat.isDirectory()) { + const children = fs.readdirSync(absolutePath, { withFileTypes: true }).sort(function (left, right) { + return left.name.localeCompare(right.name); + }); + + for (const child of children) { + collectFileEntries( + path.join(absolutePath, child.name), + toPosixPath(path.join(relativePath, child.name)), + entries + ); + } + return; + } + + entries.push({ + sourcePath: absolutePath, + relativePath: toPosixPath(relativePath), + }); +} + +function getReleaseEntries(projectRoot = PROJECT_ROOT) { + const entries = []; + + for (const relativeEntry of RELEASE_ROOT_ENTRIES) { + const sourcePath = path.join(projectRoot, relativeEntry); + if (!fs.existsSync(sourcePath)) { + throw new Error(`Release file is missing: ${relativeEntry}`); + } + collectFileEntries(sourcePath, relativeEntry, entries); + } + + return entries.sort(function (left, right) { + return left.relativePath.localeCompare(right.relativePath); + }); +} + +function copyReleaseFiles(projectRoot = PROJECT_ROOT, outputDir = CHROME_DIST_DIR) { + ensureCleanDir(outputDir); + + const entries = getReleaseEntries(projectRoot); + for (const entry of entries) { + const destinationPath = path.join(outputDir, entry.relativePath); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.copyFileSync(entry.sourcePath, destinationPath); + } + + return entries.map(function (entry) { + return entry.relativePath; + }); +} + +function listFilesRecursive(rootDir) { + if (!fs.existsSync(rootDir)) { + return []; + } + + const results = []; + + function visit(currentDir) { + const children = fs.readdirSync(currentDir, { withFileTypes: true }).sort(function (left, right) { + return left.name.localeCompare(right.name); + }); + + for (const child of children) { + const absoluteChild = path.join(currentDir, child.name); + if (child.isDirectory()) { + visit(absoluteChild); + continue; + } + + results.push(toPosixPath(path.relative(rootDir, absoluteChild))); + } + } + + visit(rootDir); + return results; +} + +function verifyChromeBuildOutput(outputDir, expectedRelativePaths) { + const actual = listFilesRecursive(outputDir).sort(); + const expected = Array.from(new Set(expectedRelativePaths.map(toPosixPath))).sort(); + + return { + actual, + expected, + missing: expected.filter(function (item) { + return !actual.includes(item); + }), + extras: actual.filter(function (item) { + return !expected.includes(item); + }), + }; +} + +function buildCrc32Table() { + const table = new Uint32Array(256); + for (let index = 0; index < 256; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1); + } + table[index] = value >>> 0; + } + return table; +} + +const CRC32_TABLE = buildCrc32Table(); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createDeterministicZipFromDirectory(sourceDir, zipPath) { + const files = listFilesRecursive(sourceDir).sort(); + const fixedDosTime = 0; + const fixedDosDate = ((1980 - 1980) << 9) | (1 << 5) | 1; + const localChunks = []; + const centralChunks = []; + let localOffset = 0; + + for (const relativePath of files) { + const absolutePath = path.join(sourceDir, relativePath); + const nameBuffer = Buffer.from(toPosixPath(relativePath), "utf8"); + const fileBuffer = fs.readFileSync(absolutePath); + const checksum = crc32(fileBuffer); + + const localHeader = Buffer.alloc(30 + nameBuffer.length); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(0, 6); + localHeader.writeUInt16LE(0, 8); + localHeader.writeUInt16LE(fixedDosTime, 10); + localHeader.writeUInt16LE(fixedDosDate, 12); + localHeader.writeUInt32LE(checksum, 14); + localHeader.writeUInt32LE(fileBuffer.length, 18); + localHeader.writeUInt32LE(fileBuffer.length, 22); + localHeader.writeUInt16LE(nameBuffer.length, 26); + localHeader.writeUInt16LE(0, 28); + nameBuffer.copy(localHeader, 30); + + localChunks.push(localHeader, fileBuffer); + + const centralHeader = Buffer.alloc(46 + nameBuffer.length); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(0, 8); + centralHeader.writeUInt16LE(0, 10); + centralHeader.writeUInt16LE(fixedDosTime, 12); + centralHeader.writeUInt16LE(fixedDosDate, 14); + centralHeader.writeUInt32LE(checksum, 16); + centralHeader.writeUInt32LE(fileBuffer.length, 20); + centralHeader.writeUInt32LE(fileBuffer.length, 24); + centralHeader.writeUInt16LE(nameBuffer.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE(0, 38); + centralHeader.writeUInt32LE(localOffset, 42); + nameBuffer.copy(centralHeader, 46); + centralChunks.push(centralHeader); + + localOffset += localHeader.length + fileBuffer.length; + } + + const centralDirectory = Buffer.concat(centralChunks); + const endOfCentralDirectory = Buffer.alloc(22); + endOfCentralDirectory.writeUInt32LE(0x06054b50, 0); + endOfCentralDirectory.writeUInt16LE(0, 4); + endOfCentralDirectory.writeUInt16LE(0, 6); + endOfCentralDirectory.writeUInt16LE(files.length, 8); + endOfCentralDirectory.writeUInt16LE(files.length, 10); + endOfCentralDirectory.writeUInt32LE(centralDirectory.length, 12); + endOfCentralDirectory.writeUInt32LE(localOffset, 16); + endOfCentralDirectory.writeUInt16LE(0, 20); + + fs.mkdirSync(path.dirname(zipPath), { recursive: true }); + fs.writeFileSync(zipPath, Buffer.concat(localChunks.concat([centralDirectory, endOfCentralDirectory]))); + return files; +} + +module.exports = { + CHROME_DIST_DIR, + DIST_ROOT, + PROJECT_ROOT, + RELEASE_ARCHIVE_PREFIX, + RELEASE_ROOT_ENTRIES, + copyReleaseFiles, + createDeterministicZipFromDirectory, + ensureCleanDir, + getReleaseEntries, + listFilesRecursive, + readJson, + toPosixPath, + validateManifestData, + verifyChromeBuildOutput, +}; diff --git a/scripts/pack-chrome.cjs b/scripts/pack-chrome.cjs new file mode 100644 index 0000000..61fc51c --- /dev/null +++ b/scripts/pack-chrome.cjs @@ -0,0 +1,35 @@ +const path = require("node:path"); +const { buildChrome } = require("./build-chrome.cjs"); +const { + DIST_ROOT, + PROJECT_ROOT, + RELEASE_ARCHIVE_PREFIX, + createDeterministicZipFromDirectory, + readJson, +} = require("./lib/release-utils.cjs"); + +function packChrome() { + const build = buildChrome(); + const pkg = readJson(path.join(PROJECT_ROOT, "package.json")); + const archivePath = path.join(DIST_ROOT, `${RELEASE_ARCHIVE_PREFIX}-v${pkg.version}.zip`); + const packedFiles = createDeterministicZipFromDirectory(build.outputDir, archivePath); + + console.log(`Chrome package created: ${archivePath}`); + console.log(`Packed ${packedFiles.length} file(s).`); + return { + archivePath, + files: packedFiles, + }; +} + +if (require.main === module) { + try { + packChrome(); + } catch (error) { + console.error("Chrome packaging failed."); + console.error(error.message || error); + process.exitCode = 1; + } +} + +module.exports = { packChrome }; diff --git a/shared.js b/shared.js index b6d37cf..35fd15e 100644 --- a/shared.js +++ b/shared.js @@ -466,6 +466,110 @@ .trim(); } + function isExtensionContextValid() { + try { + return typeof chrome !== "undefined" + && !!chrome.runtime + && typeof chrome.runtime.id === "string" + && chrome.runtime.id.length > 0; + } catch (error) { + return false; + } + } + + function isExtensionContextInvalidatedError(error) { + var message = String((error && error.message) || error || ""); + return /Extension context invalidated/i.test(message); + } + + async function safeChromeAsync(action, fallbackValue) { + if (!isExtensionContextValid()) { + return fallbackValue; + } + + try { + return await action(); + } catch (error) { + if (isExtensionContextInvalidatedError(error)) { + return fallbackValue; + } + + throw error; + } + } + + async function safeStorageGet(area, defaults) { + return safeChromeAsync(function () { + return chrome.storage[area].get(defaults); + }, defaults); + } + + async function safeStorageSet(area, value) { + return safeChromeAsync(async function () { + await chrome.storage[area].set(value); + return true; + }, false); + } + + async function safeTabsQuery(queryInfo) { + return safeChromeAsync(function () { + return chrome.tabs.query(queryInfo); + }, []); + } + + async function safeExecuteScript(details) { + return safeChromeAsync(function () { + return chrome.scripting.executeScript(details); + }, []); + } + + async function safeSendMessage(tabId, message, options) { + return safeChromeAsync(function () { + return chrome.tabs.sendMessage(tabId, message, options); + }, null); + } + + async function safeOpenOptionsPage() { + return safeChromeAsync(async function () { + await chrome.runtime.openOptionsPage(); + return true; + }, false); + } + + function addListenerSafely(eventObject, listener) { + if (!isExtensionContextValid() || !eventObject || typeof eventObject.addListener !== "function") { + return false; + } + + try { + eventObject.addListener(listener); + return true; + } catch (error) { + if (isExtensionContextInvalidatedError(error)) { + return false; + } + + throw error; + } + } + + function removeListenerSafely(eventObject, listener) { + if (!eventObject || typeof eventObject.removeListener !== "function") { + return false; + } + + try { + eventObject.removeListener(listener); + return true; + } catch (error) { + if (isExtensionContextInvalidatedError(error)) { + return false; + } + + throw error; + } + } + return { DEFAULT_SETTINGS: DEFAULT_SETTINGS, DEFAULT_ANALYTICS: DEFAULT_ANALYTICS, @@ -502,5 +606,16 @@ filterHistoryEntries: filterHistoryEntries, getHistoryHostOptions: getHistoryHostOptions, sanitizeText: sanitizeText, + isExtensionContextValid: isExtensionContextValid, + isExtensionContextInvalidatedError: isExtensionContextInvalidatedError, + safeChromeAsync: safeChromeAsync, + safeStorageGet: safeStorageGet, + safeStorageSet: safeStorageSet, + safeTabsQuery: safeTabsQuery, + safeExecuteScript: safeExecuteScript, + safeSendMessage: safeSendMessage, + safeOpenOptionsPage: safeOpenOptionsPage, + addListenerSafely: addListenerSafely, + removeListenerSafely: removeListenerSafely, }; }); diff --git a/tests/release-utils.test.js b/tests/release-utils.test.js new file mode 100644 index 0000000..66304aa --- /dev/null +++ b/tests/release-utils.test.js @@ -0,0 +1,71 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const releaseUtils = require("../scripts/lib/release-utils.cjs"); + +test("validateManifestData rejects Firefox-only fields in the Chrome manifest", function () { + const errors = releaseUtils.validateManifestData({ + version: "1.0.0", + manifest_version: 3, + browser_specific_settings: { gecko: {} }, + background: { + service_worker: "background.js", + scripts: ["background.js"], + }, + action: { + default_popup: "popup.html", + }, + permissions: [], + host_permissions: [], + }, { + version: "1.0.1", + }); + + assert.ok(errors.some(function (error) { + return error.includes("browser_specific_settings"); + })); + assert.ok(errors.some(function (error) { + return error.includes("background.scripts"); + })); + assert.ok(errors.some(function (error) { + return error.includes("package.json version"); + })); +}); + +test("getReleaseEntries includes shipped files and excludes repo-only files", function () { + const entries = releaseUtils.getReleaseEntries(); + const relativePaths = entries.map(function (entry) { + return entry.relativePath; + }); + + assert.ok(relativePaths.includes("manifest.json")); + assert.ok(relativePaths.includes("background.js")); + assert.ok(relativePaths.some(function (entry) { + return entry.startsWith("_locales/") && entry.endsWith("/messages.json"); + })); + assert.ok(!relativePaths.includes("manifest.firefox.json")); + assert.ok(!relativePaths.some(function (entry) { + return entry.startsWith(".omx/"); + })); +}); + +test("createDeterministicZipFromDirectory produces stable bytes", function () { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "copy-text-zip-")); + const sourceDir = path.join(tempRoot, "source"); + const zipA = path.join(tempRoot, "a.zip"); + const zipB = path.join(tempRoot, "b.zip"); + + fs.mkdirSync(path.join(sourceDir, "nested"), { recursive: true }); + fs.writeFileSync(path.join(sourceDir, "alpha.txt"), "alpha"); + fs.writeFileSync(path.join(sourceDir, "nested", "beta.txt"), "beta"); + + releaseUtils.createDeterministicZipFromDirectory(sourceDir, zipA); + releaseUtils.createDeterministicZipFromDirectory(sourceDir, zipB); + + assert.deepEqual(fs.readFileSync(zipA), fs.readFileSync(zipB)); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); diff --git a/tests/shared.test.js b/tests/shared.test.js index 465b2ba..6a0577f 100644 --- a/tests/shared.test.js +++ b/tests/shared.test.js @@ -275,3 +275,35 @@ test("pushHistoryEntry preserves pinned status on dedup", function () { assert.equal(result[0].pinned, true, "Pinned status should be preserved from original"); assert.equal(result[0].source, "shortcut", "Source should update to latest"); }); + +test("isExtensionContextValid reflects chrome.runtime.id presence", function () { + const originalChrome = global.chrome; + + delete global.chrome; + assert.equal(utils.isExtensionContextValid(), false); + + global.chrome = { runtime: { id: "abc123" } }; + assert.equal(utils.isExtensionContextValid(), true); + + global.chrome = originalChrome; +}); + +test("isExtensionContextInvalidatedError detects the expected runtime error", function () { + assert.equal(utils.isExtensionContextInvalidatedError(new Error("Extension context invalidated.")), true); + assert.equal(utils.isExtensionContextInvalidatedError(new Error("Could not establish connection. Receiving end does not exist.")), false); +}); + +test("safeStorage wrappers return fallbacks when context is unavailable", async function () { + const originalChrome = global.chrome; + delete global.chrome; + + const storageDefaults = { ok: true }; + assert.deepEqual(await utils.safeStorageGet("sync", storageDefaults), storageDefaults); + assert.equal(await utils.safeStorageSet("sync", { ok: false }), false); + assert.deepEqual(await utils.safeTabsQuery({}), []); + assert.deepEqual(await utils.safeExecuteScript({}), []); + assert.equal(await utils.safeSendMessage(1, { ping: true }), null); + assert.equal(await utils.safeOpenOptionsPage(), false); + + global.chrome = originalChrome; +}); From dba8fe5cb7942300c254b4ca87490eb93fbcf218 Mon Sep 17 00:00:00 2001 From: VET Date: Tue, 19 May 2026 09:22:55 +0700 Subject: [PATCH 03/12] ref(extension): Modularize source and gate Chrome releases end to end Move the authored extension logic into src modules, build the shipped artifact exclusively from that source tree, and add browser automation so Chrome release verification exercises the unpacked extension instead of only unit-level helpers. This removes the split-brain risk of editing runtime files at the repo root while shipping something different. Constraint: Dist artifacts stay generated and must not become a second source of truth Constraint: Chrome-only support remains the product contract for this repository Rejected: Keep root runtime JS as active source | future edits would drift away from the build pipeline Rejected: Add only unit tests without browser automation | would miss extension lifecycle regressions Confidence: medium Scope-risk: broad Reversibility: clean Directive: Continue editing source under src and load unpacked from dist/chrome only Tested: npm run verify:release Not-tested: Manual exploratory walkthrough beyond the documented Chrome checklist Co-Authored-By: Claude --- .github/workflows/chrome-ci.yml | 26 ++ .gitignore | 2 + README.md | 65 +-- background.js | 222 ---------- package-lock.json | 563 ++++++++++++++++++++++++++ package.json | 8 +- playwright.config.cjs | 16 + popup.css | 445 ++++++++++++++++++++ popup.html | 447 +------------------- popup.js | 420 ------------------- scripts/build-chrome.cjs | 43 +- scripts/check-syntax.cjs | 13 +- scripts/lib/release-utils.cjs | 18 +- scripts/pack-chrome.cjs | 10 +- src/background/history-maintenance.js | 47 +++ src/background/index.js | 75 ++++ src/background/registration.js | 82 ++++ src/background/shortcut.js | 53 +++ menu.js => src/content/bootstrap.js | 0 options.js => src/options/index.js | 37 +- src/options/tabs.js | 50 +++ src/options/ui.js | 124 ++++++ src/popup/history-view.js | 167 ++++++++ src/popup/index.js | 120 ++++++ src/popup/state.js | 83 ++++ src/popup/ui.js | 123 ++++++ src/shared/analytics.js | 8 + shared.js => src/shared/core.js | 106 +++-- src/shared/history.js | 12 + src/shared/i18n.js | 7 + src/shared/index.js | 7 + src/shared/runtime.js | 15 + src/shared/settings.js | 22 + src/shared/text.js | 6 + tests/e2e/extension.spec.js | 260 ++++++++++++ tests/release-utils.test.js | 5 +- tests/shared.test.js | 2 +- 37 files changed, 2479 insertions(+), 1230 deletions(-) delete mode 100644 background.js create mode 100644 package-lock.json create mode 100644 playwright.config.cjs create mode 100644 popup.css delete mode 100644 popup.js create mode 100644 src/background/history-maintenance.js create mode 100644 src/background/index.js create mode 100644 src/background/registration.js create mode 100644 src/background/shortcut.js rename menu.js => src/content/bootstrap.js (100%) rename options.js => src/options/index.js (97%) create mode 100644 src/options/tabs.js create mode 100644 src/options/ui.js create mode 100644 src/popup/history-view.js create mode 100644 src/popup/index.js create mode 100644 src/popup/state.js create mode 100644 src/popup/ui.js create mode 100644 src/shared/analytics.js rename shared.js => src/shared/core.js (92%) create mode 100644 src/shared/history.js create mode 100644 src/shared/i18n.js create mode 100644 src/shared/index.js create mode 100644 src/shared/runtime.js create mode 100644 src/shared/settings.js create mode 100644 src/shared/text.js create mode 100644 tests/e2e/extension.spec.js diff --git a/.github/workflows/chrome-ci.yml b/.github/workflows/chrome-ci.yml index 59cfe0a..d83dd11 100644 --- a/.github/workflows/chrome-ci.yml +++ b/.github/workflows/chrome-ci.yml @@ -15,6 +15,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci - name: Run unit tests run: npm test @@ -27,3 +31,25 @@ jobs: - name: Build Chrome package run: npm run build:chrome + + e2e: + runs-on: ubuntu-latest + needs: validate + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Run extension e2e tests + run: npm run test:e2e diff --git a/.gitignore b/.gitignore index 3f763e5..74a1d90 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ Temporary Items web-ext-artifacts dist/ .omx/ +node_modules/ +test-results/ # AI Context .cobridge diff --git a/README.md b/README.md index 460a32d..6973297 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Copy Text with Alt-Click -Easy and fast copy tool. +Easy and fast copy tool for Chrome MV3. ## Features @@ -23,19 +23,18 @@ Easy and fast copy tool. ## Description -If you install this extension, you do not need to select text first. -Just hold the configured modifier and click the target text. +Install the extension, hold the configured modifier key, and click the target text. -The clicked text is copied immediately. +The clicked text is copied immediately without manual selection in the common case. -## How It Works +## Architecture -- `background.js` manages MV3 startup, content-script registration, shortcut handling, and storage cleanup -- `menu.js` handles hover preview, extraction, copy behavior, history, analytics, and runtime invalidation safety in page context -- `popup.html` / `popup.js` provide quick controls for the current site and the most common settings -- `options.html` / `options.js` expose the full tabbed settings and history management UI -- `shared.js` contains shared settings, history, analytics, and runtime-safe Chrome API helpers -- Local history and analytics are stored in `chrome.storage.local` +- `src/background/` contains the MV3 startup, registration, shortcut, and history-maintenance source modules +- `src/content/` contains content-script source for hover preview, extraction, copy behavior, history, and analytics +- `src/popup/` contains quick-control popup source modules +- `src/options/` contains settings and history-management source modules +- `src/shared/` contains shared settings, history, analytics, i18n, and runtime-safe Chrome API helpers +- `dist/chrome/` is the generated unpacked extension artifact ## Settings @@ -50,42 +49,50 @@ The clicked text is copied immediately. - **Local analytics**: summary cards and top domains, stored locally only - **Keyboard shortcut mode**: lets browser shortcuts trigger copy without requiring a click -## Test Fixtures - -The repo includes browser fixtures and a manual checklist: - -- `fixtures/basic-copy.html` -- `fixtures/editable-surfaces.html` -- `fixtures/keyboard-shortcut.html` -- `fixtures/selection-copy.html` -- `fixtures/table-copy.html` -- `docs/browser-manual-test-checklist.md` - -Serve the fixtures over HTTP before testing: - -```bash -python -m http.server 4173 -``` - ## Development Target - The repository is maintained for **Chrome MV3** - `manifest.json` is the only supported shipping manifest +- Authored JavaScript source lives under `src/` +- Load unpacked from `dist/chrome/`, not from the repository root ## Development -Run the repository checks with Node.js: +Run the fast contract checks: ```bash npm run validate ``` +Run the browser automation smoke suite: + +```bash +npm run test:e2e +``` + Create a release zip: ```bash npm run pack:chrome ``` +## Manual Test Fixtures + +The repo includes fixture pages and a checklist for manual browser verification: + +- `fixtures/basic-copy.html` +- `fixtures/editable-surfaces.html` +- `fixtures/keyboard-shortcut.html` +- `fixtures/selection-copy.html` +- `fixtures/table-copy.html` +- `docs/browser-manual-test-checklist.md` + +Serve the fixtures over HTTP before testing: + +```bash +python -m http.server 4173 +``` + The build output is written to `dist/chrome/` and the packaged archive is written to `dist/`. ![Screenshot](https://addons.mozilla.org/user-media/previews/full/193/193185.png?modified=1622132342) diff --git a/background.js b/background.js deleted file mode 100644 index 1299de1..0000000 --- a/background.js +++ /dev/null @@ -1,222 +0,0 @@ -if (typeof importScripts === "function") { - importScripts("shared.js"); -} - -var utils = globalThis.CopyTextUtils; -var CONTENT_SCRIPT_ID = "copy-text-with-alt-click-content"; -var HISTORY_CLEANUP_ALARM = "copy-text-history-cleanup"; - -if (!utils) { - console.error("CopyTextUtils is not available."); -} else { - utils.addListenerSafely(chrome.runtime.onInstalled, function () { - initializeExtension().catch(function (error) { - reportBackgroundError("Initializing extension on install failed.", error); - }); - }); - - utils.addListenerSafely(chrome.runtime.onStartup, function () { - initializeExtension().catch(function (error) { - reportBackgroundError("Initializing extension on startup failed.", error); - }); - }); - - utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { - if (!utils.isExtensionContextValid() || areaName != "sync") { - return; - } - - initializeExtension().catch(function (error) { - reportBackgroundError("Re-initializing extension after settings change failed.", error); - }); - }); - - utils.addListenerSafely(chrome.commands.onCommand, function (command) { - triggerShortcutCopy(command).catch(function (error) { - reportBackgroundError("Handling shortcut command failed.", error); - }); - }); - - try { - chrome.alarms.create(HISTORY_CLEANUP_ALARM, { periodInMinutes: 360 }); - utils.addListenerSafely(chrome.alarms.onAlarm, function (alarm) { - if (!alarm || alarm.name != HISTORY_CLEANUP_ALARM) { - return; - } - - initializeHistoryCleanup().catch(function (error) { - reportBackgroundError("Running history cleanup failed.", error); - }); - }); - } catch (error) { - reportBackgroundError("Scheduling history cleanup failed.", error); - } -} - -async function initializeExtension() { - if (!utils.isExtensionContextValid()) { - return; - } - - var settings = await ensureSettings(); - await syncContentScriptRegistration(settings); - await injectContentScriptsIntoOpenTabs(settings); - await trimHistory(settings.copyHistoryLimit); -} - -async function ensureSettings() { - var current = await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS); - var merged = utils.mergeSettings(current); - await utils.safeStorageSet("sync", merged); - return merged; -} - -async function syncContentScriptRegistration(settings) { - if (!utils.isExtensionContextValid()) { - return; - } - - try { - await utils.safeChromeAsync(function () { - return chrome.scripting.unregisterContentScripts({ ids: [CONTENT_SCRIPT_ID] }); - }, true); - } catch (error) { - reportBackgroundError("Unregistering prior content scripts failed.", error); - } - - await utils.safeChromeAsync(function () { - return chrome.scripting.registerContentScripts([{ - id: CONTENT_SCRIPT_ID, - matches: ["http://*/*", "https://*/*"], - excludeMatches: utils.buildExcludeMatches(settings.excludedDomains), - js: ["shared.js", "menu.js"], - runAt: "document_start", - persistAcrossSessions: true, - }]); - }, false); -} - -async function injectContentScriptsIntoOpenTabs(settings) { - var tabs = await utils.safeTabsQuery({}); - - await Promise.all(tabs.map(async function (tab) { - if (!tab.id || !tab.url) { - return; - } - - var hostname = utils.getHostnameFromUrl(tab.url); - if (!hostname || utils.isExcludedHost(hostname, settings.excludedDomains)) { - return; - } - - try { - await utils.safeExecuteScript({ - target: { tabId: tab.id }, - files: ["shared.js", "menu.js"], - }); - } catch (error) { - reportBackgroundError("Injecting content scripts into an open tab failed.", error); - } - })); -} - -async function triggerShortcutCopy(command) { - if (command != "copy-focused-target") { - return; - } - - var settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); - if (!settings.keyboardShortcutEnabled) { - return; - } - - var tabs = await utils.safeTabsQuery({ active: true, lastFocusedWindow: true }); - var activeTab = tabs[0]; - if (!activeTab || !activeTab.id || !activeTab.url) { - return; - } - - var hostname = utils.getHostnameFromUrl(activeTab.url); - if (!hostname || utils.isExcludedHost(hostname, settings.excludedDomains)) { - if (hostname && utils.isExcludedHost(hostname, settings.excludedDomains)) { - await saveAnalyticsEvent({ - type: "blockedExcluded", - hostname: hostname, - toastKind: "status", - }); - } - return; - } - - try { - await chrome.tabs.sendMessage(activeTab.id, { - type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", - }); - } catch (error) { - if (utils.isExtensionContextInvalidatedError(error)) { - return; - } - - try { - await utils.safeExecuteScript({ - target: { tabId: activeTab.id }, - files: ["shared.js", "menu.js"], - }); - - await chrome.tabs.sendMessage(activeTab.id, { - type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", - }); - } catch (secondError) { - if (utils.isExtensionContextInvalidatedError(secondError)) { - return; - } - } - } -} - -async function trimHistory(limit) { - var current = await utils.safeStorageGet("local", { copyHistory: [] }); - var history = Array.isArray(current.copyHistory) ? current.copyHistory : []; - - if (history.length > limit) { - history = history.slice(0, limit); - } - - var maxBytes = 4 * 1024 * 1024; - var serialized = JSON.stringify(history); - while (serialized.length > maxBytes && history.length > 1) { - var indexToRemove = -1; - for (var i = history.length - 1; i >= 0; i--) { - if (!history[i].pinned) { - indexToRemove = i; - break; - } - } - if (indexToRemove === -1) { - indexToRemove = history.length - 1; - } - history.splice(indexToRemove, 1); - serialized = JSON.stringify(history); - } - - await utils.safeStorageSet("local", { copyHistory: history }); -} - -async function initializeHistoryCleanup() { - var settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); - await trimHistory(settings.copyHistoryLimit); -} - -async function saveAnalyticsEvent(event) { - var current = await utils.safeStorageGet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); - var nextAnalytics = utils.recordAnalyticsEvent(current.copyAnalytics, event || {}); - await utils.safeStorageSet("local", { copyAnalytics: nextAnalytics }); -} - -function reportBackgroundError(message, error) { - if (utils.isExtensionContextInvalidatedError(error)) { - return; - } - - console.warn(message, error); -} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..68f08a1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,563 @@ +{ + "name": "copy-text-without-selecting", + "version": "2.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copy-text-without-selecting", + "version": "2.4.0", + "devDependencies": { + "@playwright/test": "^1.60.0", + "esbuild": "^0.28.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json index 5307b1b..222d18d 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,16 @@ "description": "Manifest V3 browser extension for copying text with modifier-assisted clicks.", "scripts": { "test": "node --test", + "test:e2e": "npm run build:chrome && playwright test", "check:syntax": "node scripts/check-syntax.cjs", "check:manifest": "node scripts/check-manifest.cjs", "validate": "npm run test && npm run check:syntax && npm run check:manifest && npm run build:chrome", "build:chrome": "node scripts/build-chrome.cjs", - "pack:chrome": "node scripts/pack-chrome.cjs" + "pack:chrome": "node scripts/pack-chrome.cjs", + "verify:release": "npm run validate && npm run test:e2e && npm run pack:chrome" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "esbuild": "^0.28.0" } } diff --git a/playwright.config.cjs b/playwright.config.cjs new file mode 100644 index 0000000..3bcab05 --- /dev/null +++ b/playwright.config.cjs @@ -0,0 +1,16 @@ +const path = require("node:path"); + +/** @type {import('@playwright/test').PlaywrightTestConfig} */ +module.exports = { + testDir: path.join(__dirname, "tests", "e2e"), + timeout: 120000, + workers: 1, + fullyParallel: false, + reporter: [["list"]], + use: { + headless: true, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, +}; diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..55409bf --- /dev/null +++ b/popup.css @@ -0,0 +1,445 @@ +:root { + color-scheme: light dark; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + + /* ── Light tokens ── */ + --bg-a: rgba(14, 165, 233, 0.16); + --bg-b: #f8fbff; + --bg-c: #eef6ff; + --surface: rgba(255, 255, 255, 0.82); + --surface-strong: rgba(255, 255, 255, 0.92); + --surface-item: rgba(248, 250, 252, 0.9); + --border: rgba(148, 163, 184, 0.28); + --border-strong: rgba(148, 163, 184, 0.45); + --border-item: rgba(226, 232, 240, 0.95); + --text: #0f172a; + --text-secondary: #334155; + --muted: #64748b; + --accent: #0f766e; + --accent-alt: #0369a1; + --accent-surface: rgba(15, 118, 110, 0.08); + --accent-border: rgba(15, 118, 110, 0.22); + --badge-ok-bg: rgba(16, 185, 129, 0.14); + --badge-ok-fg: #047857; + --badge-err-bg: rgba(239, 68, 68, 0.14); + --badge-err-fg: #b91c1c; + --badge-mute-bg: rgba(148, 163, 184, 0.18); + --badge-mute-fg: #475569; + --shadow: 0 10px 28px rgba(15, 23, 42, 0.08); + --shadow-btn: 0 10px 24px rgba(8, 47, 73, 0.18); + --chip-bg: rgba(255, 255, 255, 0.92); + --chip-border: rgba(203, 213, 225, 0.9); + --chip-native-fg: #475569; + --chip-native-border: rgba(148, 163, 184, 0.35); + --chip-native-bg: rgba(148, 163, 184, 0.12); + --focus-ring: rgba(14, 165, 233, 0.5); + } + + @media (prefers-color-scheme: dark) { + :root { + --bg-a: rgba(14, 165, 233, 0.06); + --bg-b: #0c1222; + --bg-c: #0f172a; + --surface: rgba(30, 41, 59, 0.82); + --surface-strong: rgba(30, 41, 59, 0.96); + --surface-item: rgba(30, 41, 59, 0.72); + --border: rgba(100, 116, 139, 0.22); + --border-strong: rgba(100, 116, 139, 0.38); + --border-item: rgba(51, 65, 85, 0.7); + --text: #e2e8f0; + --text-secondary: #cbd5e1; + --muted: #94a3b8; + --accent: #2dd4bf; + --accent-alt: #38bdf8; + --accent-surface: rgba(45, 212, 191, 0.1); + --accent-border: rgba(45, 212, 191, 0.22); + --badge-ok-bg: rgba(45, 212, 191, 0.16); + --badge-ok-fg: #2dd4bf; + --badge-err-bg: rgba(248, 113, 113, 0.16); + --badge-err-fg: #f87171; + --badge-mute-bg: rgba(100, 116, 139, 0.2); + --badge-mute-fg: #94a3b8; + --shadow: 0 10px 28px rgba(0, 0, 0, 0.35); + --shadow-btn: 0 10px 24px rgba(0, 0, 0, 0.35); + --chip-bg: rgba(30, 41, 59, 0.9); + --chip-border: rgba(51, 65, 85, 0.7); + --chip-native-fg: #94a3b8; + --chip-native-border: rgba(100, 116, 139, 0.35); + --chip-native-bg: rgba(51, 65, 85, 0.4); + --focus-ring: rgba(56, 189, 248, 0.5); + } + } + + * { box-sizing: border-box; } + + body { + margin: 0; + min-width: 340px; + background: + radial-gradient(circle at top right, var(--bg-a), transparent 42%), + linear-gradient(180deg, var(--bg-b) 0%, var(--bg-c) 100%); + color: var(--text); + -webkit-font-smoothing: antialiased; + } + + .panel { + padding: 16px; + display: grid; + gap: 14px; + } + + .hero, + .card { + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface); + box-shadow: var(--shadow); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + } + + .hero { + padding: 14px 14px 12px; + display: grid; + gap: 6px; + } + + .eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + } + + .title { + font-size: 16px; + font-weight: 700; + } + + .subtitle { + font-size: 12px; + line-height: 1.5; + color: var(--muted); + } + + .card { + padding: 14px; + display: grid; + gap: 12px; + } + + .row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .stack { + display: grid; + gap: 4px; + } + + .label { + font-size: 13px; + font-weight: 650; + color: var(--text-secondary); + } + + .hint { + font-size: 12px; + line-height: 1.45; + color: var(--muted); + } + + .domain { + font-size: 14px; + font-weight: 700; + word-break: break-word; + } + + .badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; + transition: transform 200ms ease; + } + + .badge.active { + background: var(--badge-ok-bg); + color: var(--badge-ok-fg); + } + + .badge.excluded { + background: var(--badge-err-bg); + color: var(--badge-err-fg); + } + + .badge.unsupported { + background: var(--badge-mute-bg); + color: var(--badge-mute-fg); + } + + .control { + width: 100%; + font: inherit; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border-strong); + border-radius: 12px; + padding: 10px 12px; + transition: border-color 200ms ease, box-shadow 200ms ease; + } + + .control:hover { + border-color: var(--accent); + } + + .control:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus-ring); + } + + .button { + font: inherit; + cursor: pointer; + border: 0; + border-radius: 12px; + padding: 10px 12px; + font-weight: 600; + transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 200ms ease, + opacity 200ms ease, + background 200ms ease; + } + + .button:hover { + transform: translateY(-1px); + } + + .button:active { + transform: translateY(0) scale(0.98); + } + + .button:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; + } + + .button.primary { + color: #ecfeff; + background: linear-gradient(135deg, var(--accent), var(--accent-alt)); + box-shadow: var(--shadow-btn); + } + + .button.primary:hover { + box-shadow: var(--shadow-btn), 0 0 0 1px var(--accent); + } + + .button.secondary { + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--border-strong); + } + + .button.secondary:hover { + border-color: var(--accent-border); + background: var(--accent-surface); + } + + .button:disabled { + cursor: not-allowed; + opacity: 0.45; + transform: none; + box-shadow: none; + } + + .toggle { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 6px 2px; + border-radius: 10px; + transition: background 200ms ease; + } + + .toggle:hover { + background: var(--accent-surface); + } + + .toggle input { + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--accent); + } + + .footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + } + + .status { + min-height: 18px; + font-size: 12px; + font-weight: 600; + color: var(--accent); + transition: opacity 300ms ease; + } + + .history-list { + display: grid; + gap: 10px; + max-height: 280px; + overflow: auto; + } + + .history-item { + display: grid; + gap: 12px; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border-item); + background: var(--surface-item); + transition: border-color 200ms ease, transform 200ms cubic-bezier(0.22, 1, 0.36, 1); + } + + .history-item:hover { + border-color: var(--accent-border); + transform: translateY(-1px); + } + + .history-top { + display: flex; + justify-content: space-between; + align-items: start; + gap: 10px; + } + + .history-snippet { + font-size: 12px; + line-height: 1.55; + color: var(--text); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .history-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .history-chip { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1; + color: var(--muted); + background: var(--chip-bg); + border: 1px solid var(--chip-border); + } + + .history-chip.source-extension, + .history-chip.source-shortcut, + .history-chip.source-history { + color: var(--accent); + border-color: var(--accent-border); + background: var(--accent-surface); + } + + .history-chip.source-native { + color: var(--chip-native-fg); + border-color: var(--chip-native-border); + background: var(--chip-native-bg); + } + + .history-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .button.small { + padding: 7px 10px; + border-radius: 10px; + font-size: 12px; + } + + /* ── Empty state ── */ + .empty-state { + text-align: center; + padding: 24px 16px; + color: var(--muted); + } + + .empty-state-icon { + font-size: 28px; + margin-bottom: 6px; + opacity: 0.45; + } + + .empty-state-text { + font-size: 12px; + line-height: 1.5; + } + + .empty-state-hint { + font-size: 11px; + margin-top: 4px; + opacity: 0.7; + } + + /* ── Scrollbar ── */ + .history-list { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; + } + + .history-list::-webkit-scrollbar { + width: 6px; + } + + .history-list::-webkit-scrollbar-track { + background: transparent; + } + + .history-list::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 999px; + } + + .history-list::-webkit-scrollbar-thumb:hover { + background: var(--accent); + } + + /* ── Select dark mode ── */ + @media (prefers-color-scheme: dark) { + select.control option { + background: #1e293b; + color: #e2e8f0; + } + } + + /* ── Reduced motion ── */ + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } + } diff --git a/popup.html b/popup.html index 0757fdd..2da411f 100644 --- a/popup.html +++ b/popup.html @@ -4,453 +4,8 @@ Copy text with Alt-Click -
diff --git a/popup.js b/popup.js deleted file mode 100644 index 211b6f7..0000000 --- a/popup.js +++ /dev/null @@ -1,420 +0,0 @@ -(function () { - var utils = globalThis.CopyTextUtils; - var currentHostname = ""; - var settings = utils ? utils.mergeSettings() : null; - - if (!utils) { - console.error("CopyTextUtils is not available."); - return; - } - - function isExtensionUsable() { - return utils.isExtensionContextValid(); - } - - function reportPopupError(message, error) { - if (utils.isExtensionContextInvalidatedError(error)) { - return true; - } - - console.warn(message, error); - return false; - } - - var elements = { - site: document.getElementById("current_site"), - siteStatus: document.getElementById("current_site_status"), - toggleSite: document.getElementById("toggle_site"), - metaKey: document.getElementById("popup_meta_key"), - previewEnabled: document.getElementById("popup_preview_enabled"), - avoidEditable: document.getElementById("popup_avoid_editable"), - toastDuration: document.getElementById("popup_toast_duration"), - copyHistoryLimit: document.getElementById("popup_copy_history_limit"), - openOptions: document.getElementById("open_options"), - clearHistory: document.getElementById("clear_history"), - historyList: document.getElementById("history_list"), - status: document.getElementById("popup_status"), - }; - - document.addEventListener("DOMContentLoaded", initializePopup); - - async function initializePopup() { - if (!isExtensionUsable()) { - return; - } - - try { - await restoreSettings(); - applyMessages(); - await Promise.all([detectCurrentSite(), renderHistory()]); - } catch (error) { - if (reportPopupError("Initializing popup failed.", error)) { - return; - } - } - - elements.metaKey.addEventListener("change", saveSettings); - elements.previewEnabled.addEventListener("change", saveSettings); - elements.avoidEditable.addEventListener("change", saveSettings); - elements.toastDuration.addEventListener("input", saveSettings); - elements.toastDuration.addEventListener("change", saveSettings); - elements.copyHistoryLimit.addEventListener("input", saveSettings); - elements.copyHistoryLimit.addEventListener("change", saveSettings); - elements.toggleSite.addEventListener("click", toggleCurrentSite); - elements.clearHistory.addEventListener("click", clearHistory); - elements.openOptions.addEventListener("click", function () { - utils.safeOpenOptionsPage().catch(function (error) { - reportPopupError("Opening the options page failed.", error); - }); - }); - - utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { - if (!isExtensionUsable()) { - return; - } - - if (areaName == "sync") { - restoreSettings().then(function () { - applyMessages(); - refreshSiteState(); - renderHistory(); - }).catch(function (error) { - reportPopupError("Refreshing popup after settings change failed.", error); - }); - } - - if (areaName == "local" && changes.copyHistory) { - renderHistory().catch(function (error) { - reportPopupError("Refreshing popup history failed.", error); - }); - } - }); - } - - function applyMessages() { - setText("popup_eyebrow", "Quick controls"); - setText("popup_title", "Copy text with Alt-Click"); - setText("popup_subtitle", "Tweak the current site and the most-used interaction settings without opening the full options page."); - setText("popup_site_label", "Current site"); - setText("popup_modifier_label", "Copy modifier"); - setText("popup_preview_label", "Hover preview"); - setText("popup_preview_hint", "Show the target overlay while the modifier key is held."); - setText("popup_safe_label", "Skip editable apps"); - setText("popup_safe_hint", "Avoid copying inside contenteditable editors and rich text surfaces."); - setText("popup_duration_label", "Feedback duration"); - setText("copy_history_limit_label", "Saved history items"); - setText("popup_append_hint", "Quick controls for copy behavior, preview safety, and recent history."); - setText("popup_history_title", "Recent copies"); - setText("clear_history", "Clear history"); - setText("open_options", "Open full settings"); - } - - async function restoreSettings() { - settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); - elements.metaKey.value = settings.metaKey; - elements.previewEnabled.checked = settings.previewEnabled; - elements.avoidEditable.checked = settings.avoidEditable; - elements.toastDuration.value = String(settings.toastDurationMs); - if (elements.copyHistoryLimit) { - elements.copyHistoryLimit.value = String(settings.copyHistoryLimit); - } - } - - async function detectCurrentSite() { - var tabs = await utils.safeTabsQuery({ active: true, lastFocusedWindow: true }); - var activeTab = tabs[0]; - currentHostname = activeTab ? utils.getHostnameFromUrl(activeTab.url) : ""; - - if (!currentHostname) { - elements.site.textContent = t("popup_site_unknown", "This page is not scriptable"); - setSiteStatus("unsupported"); - elements.toggleSite.disabled = true; - return; - } - - elements.site.textContent = currentHostname; - elements.toggleSite.disabled = false; - await refreshSiteState(); - } - - async function refreshSiteState() { - var currentSettings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); - var isExcluded = utils.isExcludedHost(currentHostname, currentSettings.excludedDomains); - - setSiteStatus(isExcluded ? "excluded" : "active"); - elements.toggleSite.textContent = isExcluded - ? t("popup_toggle_include", "Allow this site") - : t("popup_toggle_exclude", "Exclude this site"); - } - - function setSiteStatus(status) { - elements.siteStatus.className = "badge " + status; - - switch (status) { - case "active": - elements.siteStatus.textContent = t("popup_site_active", "Active"); - break; - case "excluded": - elements.siteStatus.textContent = t("popup_site_excluded", "Excluded"); - break; - default: - elements.siteStatus.textContent = t("popup_site_unsupported", "Unsupported"); - break; - } - } - - async function saveSettings() { - if (!isExtensionUsable()) { - return; - } - - settings = utils.mergeSettings(Object.assign({}, settings, { - metaKey: elements.metaKey.value, - previewEnabled: elements.previewEnabled.checked, - avoidEditable: elements.avoidEditable.checked, - toastDurationMs: elements.toastDuration.value, - copyHistoryLimit: elements.copyHistoryLimit ? elements.copyHistoryLimit.value : settings.copyHistoryLimit, - })); - - await utils.safeStorageSet("sync", settings); - showStatus(t("save_status_saved", "Saved")); - } - - async function toggleCurrentSite() { - if (!currentHostname) { - return; - } - - var currentSettings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); - currentSettings.excludedDomains = utils.toggleDomain(currentSettings.excludedDomains, currentHostname); - - await utils.safeStorageSet("sync", currentSettings); - await refreshSiteState(); - showStatus(t("save_status_saved", "Saved")); - } - - async function renderHistory() { - var current = await utils.safeStorageGet("local", { copyHistory: [] }); - var history = Array.isArray(current.copyHistory) ? current.copyHistory : []; - elements.historyList.textContent = ""; - - if (!history.length) { - var empty = document.createElement("div"); - empty.className = "empty-state"; - - var icon = document.createElement("div"); - icon.className = "empty-state-icon"; - icon.textContent = "\uD83D\uDCCB"; - - var text = document.createElement("div"); - text.className = "empty-state-text"; - text.textContent = t("popup_history_empty", "No recent copies yet."); - - var hint = document.createElement("div"); - hint.className = "empty-state-hint"; - hint.textContent = t("popup_history_onboarding", "Hold " + (settings.metaKey || "Alt") + " + Click any text to copy it."); - - empty.appendChild(icon); - empty.appendChild(text); - empty.appendChild(hint); - elements.historyList.appendChild(empty); - return; - } - - history.forEach(function (item) { - elements.historyList.appendChild(createHistoryItem(item)); - }); - } - - function createHistoryItem(item) { - var wrapper = document.createElement("div"); - wrapper.className = "history-item"; - - var top = document.createElement("div"); - top.className = "history-top"; - - var snippet = document.createElement("div"); - snippet.className = "history-snippet"; - snippet.textContent = item.snippet || item.text; - - var meta = createHistoryMeta(item); - - var content = document.createElement("div"); - content.className = "stack"; - content.appendChild(snippet); - content.appendChild(meta); - - var actions = document.createElement("div"); - actions.className = "history-actions"; - - var copyButton = document.createElement("button"); - copyButton.className = "button secondary small"; - copyButton.type = "button"; - copyButton.textContent = t("history_copy_button", "Copy again"); - copyButton.addEventListener("click", function () { - replayHistory(item); - }); - - var deleteButton = document.createElement("button"); - deleteButton.className = "button secondary small"; - deleteButton.type = "button"; - deleteButton.textContent = t("history_delete_button", "Delete"); - deleteButton.addEventListener("click", function () { - deleteHistoryItem(item.id); - }); - - actions.appendChild(copyButton); - actions.appendChild(deleteButton); - top.appendChild(content); - wrapper.appendChild(top); - wrapper.appendChild(actions); - - return wrapper; - } - - function createHistoryMeta(item) { - var sourceMap = { - click: t("copy_history_source_click", "Extension click"), - shortcut: t("copy_history_source_shortcut", "Extension shortcut"), - history: t("copy_history_source_history", "History replay"), - native: t("copy_history_source_native", "Native copy"), - }; - - var meta = document.createElement("div"); - meta.className = "history-meta"; - meta.appendChild(createHistoryChip(sourceMap[item.source] || item.source || t("copy_history_source_click", "Extension click"), "source-" + normalizeSourceClass(item.source))); - - var timeChip = createHistoryChip(formatRelativeTime(item.createdAt), "time-chip"); - timeChip.title = formatAbsoluteTime(item.createdAt); - meta.appendChild(timeChip); - - var host = item.hostname || (item.url ? utils.getHostnameFromUrl(item.url) : ""); - if (host) { - meta.appendChild(createHistoryChip(host, "domain-chip")); - } - - return meta; - } - - function formatRelativeTime(value) { - var delta = Date.now() - Number(value || 0); - if (!Number.isFinite(delta) || delta < 60000) { - return t("history_recency_now", "Just now"); - } - - var minutes = Math.round(delta / 60000); - if (minutes < 60) { - return minutes + "m ago"; - } - - var hours = Math.round(minutes / 60); - if (hours < 24) { - return hours + "h ago"; - } - - return Math.round(hours / 24) + "d ago"; - } - - function formatAbsoluteTime(value) { - var timestamp = Number(value || 0); - if (!Number.isFinite(timestamp) || !timestamp) { - return ""; - } - - try { - return new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }).format(new Date(timestamp)); - } catch (error) { - return new Date(timestamp).toLocaleString(); - } - } - - function createHistoryChip(label, extraClass) { - var chip = document.createElement("span"); - chip.className = "history-chip" + (extraClass ? " " + extraClass : ""); - chip.textContent = label; - return chip; - } - - function normalizeSourceClass(source) { - if (source == "click") return "extension"; - if (source == "shortcut") return "shortcut"; - if (source == "history") return "history"; - if (source == "native") return "native"; - return "unknown"; - } - - async function replayHistory(item) { - try { - await navigator.clipboard.writeText(item.text); - showStatus(t("copy_history_recopied", "Copied from history")); - await recordReplayUsage(item, "historyReplayCopy"); - } catch (error) { - showStatus(error.message || "Clipboard error"); - } - } - - async function recordReplayUsage(item, analyticsType) { - var current = await utils.safeStorageGet("local", { - copyHistory: [], - copyAnalytics: utils.DEFAULT_ANALYTICS, - }); - var hostname = item.hostname || utils.getHostnameFromUrl(item.url || ""); - var nextHistory = utils.pushHistoryEntry(current.copyHistory, { - text: item.text, - snippet: item.snippet || item.text, - source: "history", - mode: "copy", - url: item.url || "", - hostname: hostname, - }, settings.copyHistoryLimit); - var nextAnalytics = utils.recordAnalyticsEvent(current.copyAnalytics, { - type: analyticsType, - hostname: hostname, - toastKind: "status", - }); - - await utils.safeStorageSet("local", { - copyHistory: nextHistory, - copyAnalytics: nextAnalytics, - }); - } - - async function clearHistory() { - await utils.safeStorageSet("local", { copyHistory: [] }); - await renderHistory(); - showStatus(t("copy_history_cleared", "History cleared")); - } - - async function deleteHistoryItem(historyId) { - var current = await utils.safeStorageGet("local", { copyHistory: [] }); - var nextHistory = utils.deleteHistoryEntries(current.copyHistory, [historyId]); - await utils.safeStorageSet("local", { copyHistory: nextHistory }); - await renderHistory(); - showStatus(t("history_deleted", "History item deleted")); - } - - function showStatus(message) { - elements.status.textContent = message; - clearTimeout(showStatus.timerId); - showStatus.timerId = setTimeout(function () { - elements.status.textContent = ""; - }, 1600); - } - - function setText(id, fallback) { - var element = document.getElementById(id); - if (element) { - element.textContent = t(id, fallback); - } - } - - function t(key, fallback) { - return utils.translate(settings, key, chrome.i18n.getMessage(key) || fallback, chrome.i18n.getUILanguage()); - } -})(); diff --git a/scripts/build-chrome.cjs b/scripts/build-chrome.cjs index 00f94b4..c2e5063 100644 --- a/scripts/build-chrome.cjs +++ b/scripts/build-chrome.cjs @@ -1,14 +1,42 @@ +const esbuild = require("esbuild"); const path = require("node:path"); const { + BUNDLED_OUTPUT_FILES, CHROME_DIST_DIR, PROJECT_ROOT, copyReleaseFiles, + getExpectedChromeOutputPaths, readJson, validateManifestData, verifyChromeBuildOutput, } = require("./lib/release-utils.cjs"); -function buildChrome() { +const ENTRY_POINTS = [ + { out: "background", in: path.join(PROJECT_ROOT, "src", "background", "index.js") }, + { out: "menu", in: path.join(PROJECT_ROOT, "src", "content", "bootstrap.js") }, + { out: "options", in: path.join(PROJECT_ROOT, "src", "options", "index.js") }, + { out: "popup", in: path.join(PROJECT_ROOT, "src", "popup", "index.js") }, + { out: "shared", in: path.join(PROJECT_ROOT, "src", "shared", "index.js") }, +]; + +async function bundleChromeScripts(outputDir) { + await esbuild.build({ + entryPoints: ENTRY_POINTS, + outdir: outputDir, + bundle: true, + format: "iife", + platform: "browser", + target: ["chrome120"], + sourcemap: false, + write: true, + logLevel: "silent", + entryNames: "[name]", + }); + + return BUNDLED_OUTPUT_FILES.slice(); +} + +async function buildChrome() { const manifest = readJson(path.join(PROJECT_ROOT, "manifest.json")); const pkg = readJson(path.join(PROJECT_ROOT, "package.json")); const errors = validateManifestData(manifest, pkg); @@ -16,8 +44,9 @@ function buildChrome() { throw new Error(errors.join("\n")); } - const expectedFiles = copyReleaseFiles(PROJECT_ROOT, CHROME_DIST_DIR); - const verification = verifyChromeBuildOutput(CHROME_DIST_DIR, expectedFiles); + copyReleaseFiles(PROJECT_ROOT, CHROME_DIST_DIR); + await bundleChromeScripts(CHROME_DIST_DIR); + const verification = verifyChromeBuildOutput(CHROME_DIST_DIR, getExpectedChromeOutputPaths(PROJECT_ROOT)); if (verification.missing.length || verification.extras.length) { const messages = []; if (verification.missing.length) { @@ -29,7 +58,7 @@ function buildChrome() { throw new Error(messages.join("\n")); } - console.log(`Chrome build complete: ${expectedFiles.length} file(s) -> ${CHROME_DIST_DIR}`); + console.log(`Chrome build complete: ${verification.actual.length} file(s) -> ${CHROME_DIST_DIR}`); return { outputDir: CHROME_DIST_DIR, files: verification.actual, @@ -37,13 +66,11 @@ function buildChrome() { } if (require.main === module) { - try { - buildChrome(); - } catch (error) { + buildChrome().catch(function (error) { console.error("Chrome build failed."); console.error(error.message || error); process.exitCode = 1; - } + }); } module.exports = { buildChrome }; diff --git a/scripts/check-syntax.cjs b/scripts/check-syntax.cjs index c04e82b..eab211b 100644 --- a/scripts/check-syntax.cjs +++ b/scripts/check-syntax.cjs @@ -29,18 +29,9 @@ function listScriptFiles(rootDir) { } function main() { - const extensionFiles = [ - "background.js", - "menu.js", - "options.js", - "popup.js", - "shared.js", - ].map(function (fileName) { - return path.join(PROJECT_ROOT, fileName); - }); - + const sourceFiles = listScriptFiles(path.join(PROJECT_ROOT, "src")); const scriptFiles = listScriptFiles(path.join(PROJECT_ROOT, "scripts")); - const filesToCheck = extensionFiles.concat(scriptFiles); + const filesToCheck = sourceFiles.concat(scriptFiles); for (const filePath of filesToCheck) { execFileSync(process.execPath, ["--check", filePath], { diff --git a/scripts/lib/release-utils.cjs b/scripts/lib/release-utils.cjs index b91bb35..151642d 100644 --- a/scripts/lib/release-utils.cjs +++ b/scripts/lib/release-utils.cjs @@ -7,15 +7,18 @@ const CHROME_DIST_DIR = path.join(DIST_ROOT, "chrome"); const RELEASE_ARCHIVE_PREFIX = "copy-text-with-alt-click-chrome"; const RELEASE_ROOT_ENTRIES = [ "_locales", - "background.js", "icon.png", "LICENSE.txt", "manifest.json", - "menu.js", "options.css", "options.html", - "options.js", + "popup.css", "popup.html", +]; +const BUNDLED_OUTPUT_FILES = [ + "background.js", + "menu.js", + "options.js", "popup.js", "shared.js", ]; @@ -120,6 +123,13 @@ function getReleaseEntries(projectRoot = PROJECT_ROOT) { }); } +function getExpectedChromeOutputPaths(projectRoot = PROJECT_ROOT) { + const copiedEntries = getReleaseEntries(projectRoot).map(function (entry) { + return entry.relativePath; + }); + return copiedEntries.concat(BUNDLED_OUTPUT_FILES).sort(); +} + function copyReleaseFiles(projectRoot = PROJECT_ROOT, outputDir = CHROME_DIST_DIR) { ensureCleanDir(outputDir); @@ -271,6 +281,7 @@ function createDeterministicZipFromDirectory(sourceDir, zipPath) { } module.exports = { + BUNDLED_OUTPUT_FILES, CHROME_DIST_DIR, DIST_ROOT, PROJECT_ROOT, @@ -279,6 +290,7 @@ module.exports = { copyReleaseFiles, createDeterministicZipFromDirectory, ensureCleanDir, + getExpectedChromeOutputPaths, getReleaseEntries, listFilesRecursive, readJson, diff --git a/scripts/pack-chrome.cjs b/scripts/pack-chrome.cjs index 61fc51c..c731bd8 100644 --- a/scripts/pack-chrome.cjs +++ b/scripts/pack-chrome.cjs @@ -8,8 +8,8 @@ const { readJson, } = require("./lib/release-utils.cjs"); -function packChrome() { - const build = buildChrome(); +async function packChrome() { + const build = await buildChrome(); const pkg = readJson(path.join(PROJECT_ROOT, "package.json")); const archivePath = path.join(DIST_ROOT, `${RELEASE_ARCHIVE_PREFIX}-v${pkg.version}.zip`); const packedFiles = createDeterministicZipFromDirectory(build.outputDir, archivePath); @@ -23,13 +23,11 @@ function packChrome() { } if (require.main === module) { - try { - packChrome(); - } catch (error) { + packChrome().catch(function (error) { console.error("Chrome packaging failed."); console.error(error.message || error); process.exitCode = 1; - } + }); } module.exports = { packChrome }; diff --git a/src/background/history-maintenance.js b/src/background/history-maintenance.js new file mode 100644 index 0000000..d2551e2 --- /dev/null +++ b/src/background/history-maintenance.js @@ -0,0 +1,47 @@ +const HISTORY_CLEANUP_ALARM = "copy-text-history-cleanup"; + +async function trimHistory(utils, limit) { + const current = await utils.safeStorageGet("local", { copyHistory: [] }); + let history = Array.isArray(current.copyHistory) ? current.copyHistory : []; + + if (history.length > limit) { + history = history.slice(0, limit); + } + + const maxBytes = 4 * 1024 * 1024; + let serialized = JSON.stringify(history); + while (serialized.length > maxBytes && history.length > 1) { + let indexToRemove = -1; + for (let index = history.length - 1; index >= 0; index -= 1) { + if (!history[index].pinned) { + indexToRemove = index; + break; + } + } + if (indexToRemove === -1) { + indexToRemove = history.length - 1; + } + history.splice(indexToRemove, 1); + serialized = JSON.stringify(history); + } + + await utils.safeStorageSet("local", { copyHistory: history }); +} + +async function initializeHistoryCleanup(utils, trimHistoryImpl) { + const settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); + await trimHistoryImpl(settings.copyHistoryLimit); +} + +async function saveAnalyticsEvent(utils, event) { + const current = await utils.safeStorageGet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); + const nextAnalytics = utils.recordAnalyticsEvent(current.copyAnalytics, event || {}); + await utils.safeStorageSet("local", { copyAnalytics: nextAnalytics }); +} + +module.exports = { + HISTORY_CLEANUP_ALARM, + trimHistory, + initializeHistoryCleanup, + saveAnalyticsEvent, +}; diff --git a/src/background/index.js b/src/background/index.js new file mode 100644 index 0000000..c42f29e --- /dev/null +++ b/src/background/index.js @@ -0,0 +1,75 @@ +const utils = require("../shared/core.js"); +const { + HISTORY_CLEANUP_ALARM, + trimHistory, + initializeHistoryCleanup, + saveAnalyticsEvent, +} = require("./history-maintenance.js"); +const { + initializeExtension, + reportBackgroundError, +} = require("./registration.js"); +const { triggerShortcutCopy } = require("./shortcut.js"); + +const CONTENT_SCRIPT_ID = "copy-text-with-alt-click-content"; + +if (!utils) { + console.error("CopyTextUtils is not available."); +} else { + utils.addListenerSafely(chrome.runtime.onInstalled, function () { + initializeExtension(utils, CONTENT_SCRIPT_ID, function (limit) { + return trimHistory(utils, limit); + }).catch(function (error) { + reportBackgroundError(utils, "Initializing extension on install failed.", error); + }); + }); + + utils.addListenerSafely(chrome.runtime.onStartup, function () { + initializeExtension(utils, CONTENT_SCRIPT_ID, function (limit) { + return trimHistory(utils, limit); + }).catch(function (error) { + reportBackgroundError(utils, "Initializing extension on startup failed.", error); + }); + }); + + utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { + if (!utils.isExtensionContextValid() || areaName != "sync") { + return; + } + + initializeExtension(utils, CONTENT_SCRIPT_ID, function (limit) { + return trimHistory(utils, limit); + }).catch(function (error) { + reportBackgroundError(utils, "Re-initializing extension after settings change failed.", error); + }); + }); + + utils.addListenerSafely(chrome.commands.onCommand, function (command) { + if (command != "copy-focused-target") { + return; + } + + triggerShortcutCopy(utils, function (event) { + return saveAnalyticsEvent(utils, event); + }).catch(function (error) { + reportBackgroundError(utils, "Handling shortcut command failed.", error); + }); + }); + + try { + chrome.alarms.create(HISTORY_CLEANUP_ALARM, { periodInMinutes: 360 }); + utils.addListenerSafely(chrome.alarms.onAlarm, function (alarm) { + if (!alarm || alarm.name != HISTORY_CLEANUP_ALARM) { + return; + } + + initializeHistoryCleanup(utils, function (limit) { + return trimHistory(utils, limit); + }).catch(function (error) { + reportBackgroundError(utils, "Running history cleanup failed.", error); + }); + }); + } catch (error) { + reportBackgroundError(utils, "Scheduling history cleanup failed.", error); + } +} diff --git a/src/background/registration.js b/src/background/registration.js new file mode 100644 index 0000000..6ab3d13 --- /dev/null +++ b/src/background/registration.js @@ -0,0 +1,82 @@ +async function ensureSettings(utils) { + const current = await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS); + const merged = utils.mergeSettings(current); + await utils.safeStorageSet("sync", merged); + return merged; +} + +async function syncContentScriptRegistration(utils, settings, contentScriptId) { + if (!utils.isExtensionContextValid()) { + return; + } + + try { + await utils.safeChromeAsync(function () { + return chrome.scripting.unregisterContentScripts({ ids: [contentScriptId] }); + }, true); + } catch (error) { + reportBackgroundError(utils, "Unregistering prior content scripts failed.", error); + } + + await utils.safeChromeAsync(function () { + return chrome.scripting.registerContentScripts([{ + id: contentScriptId, + matches: ["http://*/*", "https://*/*"], + excludeMatches: utils.buildExcludeMatches(settings.excludedDomains), + js: ["shared.js", "menu.js"], + runAt: "document_start", + persistAcrossSessions: true, + }]); + }, false); +} + +async function injectContentScriptsIntoOpenTabs(utils, settings) { + const tabs = await utils.safeTabsQuery({}); + + await Promise.all(tabs.map(async function (tab) { + if (!tab.id || !tab.url) { + return; + } + + const hostname = utils.getHostnameFromUrl(tab.url); + if (!hostname || utils.isExcludedHost(hostname, settings.excludedDomains)) { + return; + } + + try { + await utils.safeExecuteScript({ + target: { tabId: tab.id }, + files: ["shared.js", "menu.js"], + }); + } catch (error) { + reportBackgroundError(utils, "Injecting content scripts into an open tab failed.", error); + } + })); +} + +async function initializeExtension(utils, contentScriptId, trimHistory) { + if (!utils.isExtensionContextValid()) { + return; + } + + const settings = await ensureSettings(utils); + await syncContentScriptRegistration(utils, settings, contentScriptId); + await injectContentScriptsIntoOpenTabs(utils, settings); + await trimHistory(settings.copyHistoryLimit); +} + +function reportBackgroundError(utils, message, error) { + if (utils.isExtensionContextInvalidatedError(error)) { + return; + } + + console.warn(message, error); +} + +module.exports = { + ensureSettings, + syncContentScriptRegistration, + injectContentScriptsIntoOpenTabs, + initializeExtension, + reportBackgroundError, +}; diff --git a/src/background/shortcut.js b/src/background/shortcut.js new file mode 100644 index 0000000..dac0f57 --- /dev/null +++ b/src/background/shortcut.js @@ -0,0 +1,53 @@ +async function triggerShortcutCopy(utils, saveAnalyticsEvent) { + const settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); + if (!settings.keyboardShortcutEnabled) { + return; + } + + const tabs = await utils.safeTabsQuery({ active: true, lastFocusedWindow: true }); + const activeTab = tabs[0]; + if (!activeTab || !activeTab.id || !activeTab.url) { + return; + } + + const hostname = utils.getHostnameFromUrl(activeTab.url); + if (!hostname || utils.isExcludedHost(hostname, settings.excludedDomains)) { + if (hostname && utils.isExcludedHost(hostname, settings.excludedDomains)) { + await saveAnalyticsEvent({ + type: "blockedExcluded", + hostname: hostname, + toastKind: "status", + }); + } + return; + } + + try { + await chrome.tabs.sendMessage(activeTab.id, { + type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", + }); + } catch (error) { + if (utils.isExtensionContextInvalidatedError(error)) { + return; + } + + try { + await utils.safeExecuteScript({ + target: { tabId: activeTab.id }, + files: ["shared.js", "menu.js"], + }); + + await chrome.tabs.sendMessage(activeTab.id, { + type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", + }); + } catch (secondError) { + if (utils.isExtensionContextInvalidatedError(secondError)) { + return; + } + } + } +} + +module.exports = { + triggerShortcutCopy, +}; diff --git a/menu.js b/src/content/bootstrap.js similarity index 100% rename from menu.js rename to src/content/bootstrap.js diff --git a/options.js b/src/options/index.js similarity index 97% rename from options.js rename to src/options/index.js index 641b6bc..8a57ade 100644 --- a/options.js +++ b/src/options/index.js @@ -1,4 +1,6 @@ (function () { + const { createOptionsUi } = require("./ui.js"); + const { createTabController } = require("./tabs.js"); var utils = globalThis.CopyTextUtils; var settings = utils ? utils.mergeSettings() : null; var historyFilters = { @@ -18,6 +20,14 @@ return; } + var ui = createOptionsUi({ + utils: utils, + getSettings: function () { + return settings; + }, + }); + var tabController = createTabController(); + function isExtensionUsable() { return utils.isExtensionContextValid(); } @@ -40,7 +50,7 @@ try { await restoreOptions(); - applyMessages(); + ui.applyMessages(); bindEvents(); await renderExcludedDomains(); await renderHistoryAndAnalytics(); @@ -52,30 +62,7 @@ } function bindEvents() { - document.querySelectorAll(".tab-button").forEach(function (button) { - button.addEventListener("click", function () { - activateTab(button.dataset.tab); - }); - button.addEventListener("keydown", function (event) { - var tabs = Array.from(document.querySelectorAll(".tab-button")); - var index = tabs.indexOf(button); - var next = -1; - if (event.key === "ArrowDown" || event.key === "ArrowRight") { - next = (index + 1) % tabs.length; - } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") { - next = (index - 1 + tabs.length) % tabs.length; - } else if (event.key === "Home") { - next = 0; - } else if (event.key === "End") { - next = tabs.length - 1; - } - if (next >= 0) { - event.preventDefault(); - tabs[next].focus(); - activateTab(tabs[next].dataset.tab); - } - }); - }); + tabController.bindTabEvents(); document.getElementById("meta_key").addEventListener("change", saveOptions); document.getElementById("preview_enabled").addEventListener("change", saveOptions); diff --git a/src/options/tabs.js b/src/options/tabs.js new file mode 100644 index 0000000..af24e24 --- /dev/null +++ b/src/options/tabs.js @@ -0,0 +1,50 @@ +function createTabController() { + function activateTab(tabId) { + document.querySelectorAll(".tab-button").forEach(function (button) { + const isActive = button.dataset.tab == tabId; + button.classList.toggle("active", isActive); + button.setAttribute("aria-selected", String(isActive)); + button.setAttribute("tabindex", isActive ? "0" : "-1"); + }); + + document.querySelectorAll(".tab-panel").forEach(function (panel) { + panel.classList.toggle("active", panel.dataset.panel == tabId); + }); + } + + function bindTabEvents() { + document.querySelectorAll(".tab-button").forEach(function (button) { + button.addEventListener("click", function () { + activateTab(button.dataset.tab); + }); + button.addEventListener("keydown", function (event) { + const tabs = Array.from(document.querySelectorAll(".tab-button")); + const index = tabs.indexOf(button); + let next = -1; + if (event.key === "ArrowDown" || event.key === "ArrowRight") { + next = (index + 1) % tabs.length; + } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") { + next = (index - 1 + tabs.length) % tabs.length; + } else if (event.key === "Home") { + next = 0; + } else if (event.key === "End") { + next = tabs.length - 1; + } + if (next >= 0) { + event.preventDefault(); + tabs[next].focus(); + activateTab(tabs[next].dataset.tab); + } + }); + }); + } + + return { + activateTab, + bindTabEvents, + }; +} + +module.exports = { + createTabController, +}; diff --git a/src/options/ui.js b/src/options/ui.js new file mode 100644 index 0000000..0a3422a --- /dev/null +++ b/src/options/ui.js @@ -0,0 +1,124 @@ +function createOptionsUi(context) { + function t(key, fallback) { + return context.utils.translate(context.getSettings(), key, chrome.i18n.getMessage(key) || fallback, chrome.i18n.getUILanguage()); + } + + function setText(id, fallback) { + const element = document.getElementById(id); + if (element) { + element.textContent = t(id, fallback); + } + } + + function setSelectOptionText(selectId, value, text) { + const select = document.getElementById(selectId); + const option = select && select.querySelector('option[value="' + value + '"]'); + if (option) { + option.textContent = text; + } + } + + function setHistorySelectOptions() { + setSelectOptionText("history_source_filter", "all", t("history_filter_all", "All")); + setSelectOptionText("history_source_filter", "click", t("history_filter_click", "Click")); + setSelectOptionText("history_source_filter", "shortcut", t("history_filter_shortcut", "Shortcut")); + setSelectOptionText("history_source_filter", "history", t("history_filter_history", "History replay")); + setSelectOptionText("history_source_filter", "native", t("history_filter_native", "Native copy")); + setSelectOptionText("history_mode_filter", "all", t("history_filter_all", "All")); + setSelectOptionText("history_mode_filter", "copy", t("history_filter_copy", "Copy")); + setSelectOptionText("history_domain_filter", "all", t("history_filter_all", "All")); + setSelectOptionText("history_sort", "newest", t("history_sort_newest", "Newest")); + setSelectOptionText("history_sort", "oldest", t("history_sort_oldest", "Oldest")); + setSelectOptionText("history_sort", "replayed", t("history_sort_replayed", "Most replayed")); + setSelectOptionText("history_group", "none", t("history_group_none", "None")); + setSelectOptionText("history_group", "domain", t("history_group_domain", "Domain")); + setSelectOptionText("history_group", "source", t("history_group_source", "Source")); + setSelectOptionText("history_group", "date", t("history_group_date", "Date")); + } + + function applyMessages() { + setText("settings_eyebrow", "Settings"); + setText("settings_title", "Copy text with Alt-Click"); + setText("settings_intro", "Configure how copy gestures, hover previews, safe-mode protections, and history behave across websites."); + setText("tab_general", "General"); + setText("tab_sites", "Sites"); + setText("tab_feedback", "Feedback"); + setText("tab_language", "Language"); + setText("tab_history", "History"); + setText("general_section_title", "General controls"); + setText("meta_key_label", "Copy operation"); + setText("meta_key_help", "Hold your copy modifier and click to copy page content quickly."); + setText("preview_enabled_label", "Hover preview"); + setText("preview_enabled_help", "Show the dashed outline overlay while holding the copy modifier."); + setText("avoid_editable_label", "Skip editable apps"); + setText("avoid_editable_help", "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive."); + setText("keyboard_shortcut_enabled_label", "Keyboard shortcut mode"); + setText("keyboard_shortcut_enabled_help", "Allow the browser shortcut to copy the hovered target or the focused element without clicking."); + setText("keyboard_shortcut_hint", "You can customize the extension shortcut in chrome://extensions/shortcuts."); + setText("sites_section_title", "Excluded domains"); + setText("excluded_domains_add_label", "Add domain"); + setText("add_domain_button", "Add domain"); + document.getElementById("domain_input").placeholder = t("excluded_domains_add_placeholder", "example.com"); + setText("excluded_domains_import_label", "Bulk paste domains"); + setText("excluded_domains_import_help", "Paste one domain per line and click Apply."); + setText("apply_bulk_button", "Apply list"); + setText("excluded_domains_help", "Excluded domains disable both hover previews and copy actions."); + setText("feedback_title", "Feedback behavior"); + setText("toast_duration_label", "Feedback duration"); + setText("toast_duration_help", "Custom duration for the floating copy feedback."); + setText("toast_duration_unit", "ms"); + setText("language_title", "Language"); + setText("ui_language_label", "Extension language"); + setText("ui_language_help", "Auto follows the browser locale. English and Vietnamese are available as manual overrides."); + setText("ui_language_auto", "Auto"); + setText("ui_language_en", "English"); + setText("ui_language_vi", "Tiếng Việt"); + setText("history_title", "Copy history"); + setText("analytics_title", "Local analytics"); + setText("reset_analytics_button", "Reset analytics"); + setText("analytics_total_actions", "Total actions"); + setText("analytics_append_actions", "Selection-first copies"); + setText("analytics_native_actions", "Native copies"); + setText("analytics_shortcut_actions", "Shortcut usage"); + setText("analytics_blocked_actions", "Blocked attempts"); + setText("analytics_toast_events", "Toast events"); + setText("analytics_top_domains", "Top domains"); + setText("history_search_label", "Search history"); + document.getElementById("history_search").placeholder = t("history_search_placeholder", "Search copied text or hostname"); + setText("history_filter_source_label", "Source"); + setText("history_filter_mode_label", "Mode"); + setText("history_filter_domain_label", "Domain"); + setText("history_sort_label", "Sort by"); + setText("history_group_label", "Group by"); + setHistorySelectOptions(); + setText("copy_history_limit_label", "Saved history items"); + setText("copy_history_limit_help", "How many recent copied entries should be kept locally."); + setText("history_bulk_toggle_button", "Select items"); + setText("history_bulk_cancel_button", "Cancel selection"); + setText("history_bulk_copy_button", "Copy selected"); + setText("history_bulk_delete_button", "Delete selected"); + setText("clear_history_button", "Clear history"); + } + + function showStatus(message) { + const status = document.getElementById("save_status"); + status.textContent = message; + clearTimeout(showStatus.timerId); + showStatus.timerId = setTimeout(function () { + status.textContent = ""; + }, 1800); + } + + return { + t, + setText, + setSelectOptionText, + setHistorySelectOptions, + applyMessages, + showStatus, + }; +} + +module.exports = { + createOptionsUi, +}; diff --git a/src/popup/history-view.js b/src/popup/history-view.js new file mode 100644 index 0000000..b2f3835 --- /dev/null +++ b/src/popup/history-view.js @@ -0,0 +1,167 @@ +function createPopupHistoryView(context) { + async function renderHistory() { + const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); + const history = Array.isArray(current.copyHistory) ? current.copyHistory : []; + context.elements.historyList.textContent = ""; + + if (!history.length) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + + const icon = document.createElement("div"); + icon.className = "empty-state-icon"; + icon.textContent = "\uD83D\uDCCB"; + + const text = document.createElement("div"); + text.className = "empty-state-text"; + text.textContent = context.ui.t("popup_history_empty", "No recent copies yet."); + + const hint = document.createElement("div"); + hint.className = "empty-state-hint"; + hint.textContent = context.ui.t("popup_history_onboarding", "Hold " + (context.getSettings().metaKey || "Alt") + " + Click any text to copy it."); + + empty.appendChild(icon); + empty.appendChild(text); + empty.appendChild(hint); + context.elements.historyList.appendChild(empty); + return; + } + + history.forEach(function (item) { + context.elements.historyList.appendChild(createHistoryItem(item)); + }); + } + + function createHistoryItem(item) { + const wrapper = document.createElement("div"); + wrapper.className = "history-item"; + + const top = document.createElement("div"); + top.className = "history-top"; + + const snippet = document.createElement("div"); + snippet.className = "history-snippet"; + snippet.textContent = item.snippet || item.text; + + const meta = createHistoryMeta(item); + + const content = document.createElement("div"); + content.className = "stack"; + content.appendChild(snippet); + content.appendChild(meta); + + const actions = document.createElement("div"); + actions.className = "history-actions"; + + const copyButton = document.createElement("button"); + copyButton.className = "button secondary small"; + copyButton.type = "button"; + copyButton.textContent = context.ui.t("history_copy_button", "Copy again"); + copyButton.addEventListener("click", function () { + replayHistory(item).catch(function (error) { + context.reportPopupError("Replaying popup history failed.", error); + }); + }); + + const deleteButton = document.createElement("button"); + deleteButton.className = "button secondary small"; + deleteButton.type = "button"; + deleteButton.textContent = context.ui.t("history_delete_button", "Delete"); + deleteButton.addEventListener("click", function () { + deleteHistoryItem(item.id).catch(function (error) { + context.reportPopupError("Deleting popup history failed.", error); + }); + }); + + actions.appendChild(copyButton); + actions.appendChild(deleteButton); + top.appendChild(content); + wrapper.appendChild(top); + wrapper.appendChild(actions); + + return wrapper; + } + + function createHistoryMeta(item) { + const sourceMap = { + click: context.ui.t("copy_history_source_click", "Extension click"), + shortcut: context.ui.t("copy_history_source_shortcut", "Extension shortcut"), + history: context.ui.t("copy_history_source_history", "History replay"), + native: context.ui.t("copy_history_source_native", "Native copy"), + }; + + const meta = document.createElement("div"); + meta.className = "history-meta"; + meta.appendChild(context.ui.createHistoryChip(sourceMap[item.source] || item.source || context.ui.t("copy_history_source_click", "Extension click"), "source-" + context.ui.normalizeSourceClass(item.source))); + + const timeChip = context.ui.createHistoryChip(context.ui.formatRelativeTime(context.ui.t, item.createdAt), "time-chip"); + timeChip.title = context.ui.formatAbsoluteTime(item.createdAt); + meta.appendChild(timeChip); + + const host = item.hostname || (item.url ? context.utils.getHostnameFromUrl(item.url) : ""); + if (host) { + meta.appendChild(context.ui.createHistoryChip(host, "domain-chip")); + } + + return meta; + } + + async function replayHistory(item) { + try { + await navigator.clipboard.writeText(item.text); + context.ui.showStatus(context.ui.t("copy_history_recopied", "Copied from history")); + await recordReplayUsage(item, "historyReplayCopy"); + } catch (error) { + context.ui.showStatus(error.message || "Clipboard error"); + } + } + + async function recordReplayUsage(item, analyticsType) { + const current = await context.utils.safeStorageGet("local", { + copyHistory: [], + copyAnalytics: context.utils.DEFAULT_ANALYTICS, + }); + const hostname = item.hostname || context.utils.getHostnameFromUrl(item.url || ""); + const nextHistory = context.utils.pushHistoryEntry(current.copyHistory, { + text: item.text, + snippet: item.snippet || item.text, + source: "history", + mode: "copy", + url: item.url || "", + hostname: hostname, + }, context.getSettings().copyHistoryLimit); + const nextAnalytics = context.utils.recordAnalyticsEvent(current.copyAnalytics, { + type: analyticsType, + hostname: hostname, + toastKind: "status", + }); + + await context.utils.safeStorageSet("local", { + copyHistory: nextHistory, + copyAnalytics: nextAnalytics, + }); + } + + async function clearHistory() { + await context.utils.safeStorageSet("local", { copyHistory: [] }); + await renderHistory(); + context.ui.showStatus(context.ui.t("copy_history_cleared", "History cleared")); + } + + async function deleteHistoryItem(historyId) { + const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); + const nextHistory = context.utils.deleteHistoryEntries(current.copyHistory, [historyId]); + await context.utils.safeStorageSet("local", { copyHistory: nextHistory }); + await renderHistory(); + context.ui.showStatus(context.ui.t("history_deleted", "History item deleted")); + } + + return { + renderHistory, + clearHistory, + }; +} + +module.exports = { + createPopupHistoryView, +}; diff --git a/src/popup/index.js b/src/popup/index.js new file mode 100644 index 0000000..14ce0da --- /dev/null +++ b/src/popup/index.js @@ -0,0 +1,120 @@ +const utils = require("../shared/core.js"); +const { createPopupUi } = require("./ui.js"); +const { createPopupState } = require("./state.js"); +const { createPopupHistoryView } = require("./history-view.js"); + +(function () { + let currentHostname = ""; + let settings = utils ? utils.mergeSettings() : null; + + if (!utils) { + console.error("CopyTextUtils is not available."); + return; + } + + const elements = { + site: document.getElementById("current_site"), + siteStatus: document.getElementById("current_site_status"), + toggleSite: document.getElementById("toggle_site"), + metaKey: document.getElementById("popup_meta_key"), + previewEnabled: document.getElementById("popup_preview_enabled"), + avoidEditable: document.getElementById("popup_avoid_editable"), + toastDuration: document.getElementById("popup_toast_duration"), + copyHistoryLimit: document.getElementById("popup_copy_history_limit"), + openOptions: document.getElementById("open_options"), + clearHistory: document.getElementById("clear_history"), + historyList: document.getElementById("history_list"), + status: document.getElementById("popup_status"), + }; + + function isExtensionUsable() { + return utils.isExtensionContextValid(); + } + + function reportPopupError(message, error) { + if (utils.isExtensionContextInvalidatedError(error)) { + return true; + } + + console.warn(message, error); + return false; + } + + const context = { + utils, + elements, + getSettings: function () { return settings; }, + setSettings: function (nextSettings) { settings = nextSettings; }, + get currentHostname() { return currentHostname; }, + set currentHostname(value) { currentHostname = value; }, + isExtensionUsable, + reportPopupError, + }; + + context.ui = createPopupUi(context); + context.state = createPopupState(context); + context.historyView = createPopupHistoryView(context); + + document.addEventListener("DOMContentLoaded", initializePopup); + + async function initializePopup() { + if (!isExtensionUsable()) { + return; + } + + try { + await context.state.restoreSettings(); + context.ui.applyMessages(); + await Promise.all([context.state.detectCurrentSite(), context.historyView.renderHistory()]); + } catch (error) { + if (reportPopupError("Initializing popup failed.", error)) { + return; + } + } + + elements.metaKey.addEventListener("change", context.state.saveSettings); + elements.previewEnabled.addEventListener("change", context.state.saveSettings); + elements.avoidEditable.addEventListener("change", context.state.saveSettings); + elements.toastDuration.addEventListener("input", context.state.saveSettings); + elements.toastDuration.addEventListener("change", context.state.saveSettings); + elements.copyHistoryLimit.addEventListener("input", context.state.saveSettings); + elements.copyHistoryLimit.addEventListener("change", context.state.saveSettings); + elements.toggleSite.addEventListener("click", function () { + context.state.toggleCurrentSite().catch(function (error) { + reportPopupError("Toggling popup site state failed.", error); + }); + }); + elements.clearHistory.addEventListener("click", function () { + context.historyView.clearHistory().catch(function (error) { + reportPopupError("Clearing popup history failed.", error); + }); + }); + elements.openOptions.addEventListener("click", function () { + utils.safeOpenOptionsPage().catch(function (error) { + reportPopupError("Opening the options page failed.", error); + }); + }); + + utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { + if (!isExtensionUsable()) { + return; + } + + if (areaName == "sync") { + context.state.restoreSettings().then(function () { + context.ui.applyMessages(); + context.state.refreshSiteState(); + context.historyView.renderHistory(); + }).catch(function (error) { + reportPopupError("Refreshing popup after settings change failed.", error); + }); + } + + if (areaName == "local" && changes.copyHistory) { + context.historyView.renderHistory().catch(function (error) { + reportPopupError("Refreshing popup history failed.", error); + }); + } + }); + } +})(); diff --git a/src/popup/state.js b/src/popup/state.js new file mode 100644 index 0000000..f555860 --- /dev/null +++ b/src/popup/state.js @@ -0,0 +1,83 @@ +function createPopupState(context) { + async function restoreSettings() { + const settings = context.utils.mergeSettings(await context.utils.safeStorageGet("sync", context.utils.DEFAULT_SETTINGS)); + context.setSettings(settings); + context.elements.metaKey.value = settings.metaKey; + context.elements.previewEnabled.checked = settings.previewEnabled; + context.elements.avoidEditable.checked = settings.avoidEditable; + context.elements.toastDuration.value = String(settings.toastDurationMs); + if (context.elements.copyHistoryLimit) { + context.elements.copyHistoryLimit.value = String(settings.copyHistoryLimit); + } + } + + async function detectCurrentSite() { + const tabs = await context.utils.safeTabsQuery({ active: true, lastFocusedWindow: true }); + const activeTab = tabs[0]; + context.currentHostname = activeTab ? context.utils.getHostnameFromUrl(activeTab.url) : ""; + + if (!context.currentHostname) { + context.elements.site.textContent = context.ui.t("popup_site_unknown", "This page is not scriptable"); + context.ui.setSiteStatus("unsupported"); + context.elements.toggleSite.disabled = true; + return; + } + + context.elements.site.textContent = context.currentHostname; + context.elements.toggleSite.disabled = false; + await refreshSiteState(); + } + + async function refreshSiteState() { + const currentSettings = context.utils.mergeSettings(await context.utils.safeStorageGet("sync", context.utils.DEFAULT_SETTINGS)); + const isExcluded = context.utils.isExcludedHost(context.currentHostname, currentSettings.excludedDomains); + + context.ui.setSiteStatus(isExcluded ? "excluded" : "active"); + context.elements.toggleSite.textContent = isExcluded + ? context.ui.t("popup_toggle_include", "Allow this site") + : context.ui.t("popup_toggle_exclude", "Exclude this site"); + } + + async function saveSettings() { + if (!context.isExtensionUsable()) { + return; + } + + const settings = context.utils.mergeSettings(Object.assign({}, context.getSettings(), { + metaKey: context.elements.metaKey.value, + previewEnabled: context.elements.previewEnabled.checked, + avoidEditable: context.elements.avoidEditable.checked, + toastDurationMs: context.elements.toastDuration.value, + copyHistoryLimit: context.elements.copyHistoryLimit ? context.elements.copyHistoryLimit.value : context.getSettings().copyHistoryLimit, + })); + + context.setSettings(settings); + await context.utils.safeStorageSet("sync", settings); + context.ui.showStatus(context.ui.t("save_status_saved", "Saved")); + } + + async function toggleCurrentSite() { + if (!context.currentHostname) { + return; + } + + const currentSettings = context.utils.mergeSettings(await context.utils.safeStorageGet("sync", context.utils.DEFAULT_SETTINGS)); + currentSettings.excludedDomains = context.utils.toggleDomain(currentSettings.excludedDomains, context.currentHostname); + + await context.utils.safeStorageSet("sync", currentSettings); + await refreshSiteState(); + context.ui.showStatus(context.ui.t("save_status_saved", "Saved")); + } + + return { + restoreSettings, + detectCurrentSite, + refreshSiteState, + saveSettings, + toggleCurrentSite, + }; +} + +module.exports = { + createPopupState, +}; diff --git a/src/popup/ui.js b/src/popup/ui.js new file mode 100644 index 0000000..b1679a1 --- /dev/null +++ b/src/popup/ui.js @@ -0,0 +1,123 @@ +function normalizeSourceClass(source) { + if (source == "click") return "extension"; + if (source == "shortcut") return "shortcut"; + if (source == "history") return "history"; + if (source == "native") return "native"; + return "unknown"; +} + +function formatRelativeTime(t, value) { + const delta = Date.now() - Number(value || 0); + if (!Number.isFinite(delta) || delta < 60000) { + return t("history_recency_now", "Just now"); + } + + const minutes = Math.round(delta / 60000); + if (minutes < 60) { + return minutes + "m ago"; + } + + const hours = Math.round(minutes / 60); + if (hours < 24) { + return hours + "h ago"; + } + + return Math.round(hours / 24) + "d ago"; +} + +function formatAbsoluteTime(value) { + const timestamp = Number(value || 0); + if (!Number.isFinite(timestamp) || !timestamp) { + return ""; + } + + try { + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); + } catch (error) { + return new Date(timestamp).toLocaleString(); + } +} + +function createHistoryChip(label, extraClass) { + const chip = document.createElement("span"); + chip.className = "history-chip" + (extraClass ? " " + extraClass : ""); + chip.textContent = label; + return chip; +} + +function createPopupUi(context) { + function t(key, fallback) { + return context.utils.translate(context.getSettings(), key, chrome.i18n.getMessage(key) || fallback, chrome.i18n.getUILanguage()); + } + + function setText(id, fallback) { + const element = document.getElementById(id); + if (element) { + element.textContent = t(id, fallback); + } + } + + function setSiteStatus(status) { + context.elements.siteStatus.className = "badge " + status; + + switch (status) { + case "active": + context.elements.siteStatus.textContent = t("popup_site_active", "Active"); + break; + case "excluded": + context.elements.siteStatus.textContent = t("popup_site_excluded", "Excluded"); + break; + default: + context.elements.siteStatus.textContent = t("popup_site_unsupported", "Unsupported"); + break; + } + } + + function applyMessages() { + setText("popup_eyebrow", "Quick controls"); + setText("popup_title", "Copy text with Alt-Click"); + setText("popup_subtitle", "Tweak the current site and the most-used interaction settings without opening the full options page."); + setText("popup_site_label", "Current site"); + setText("popup_modifier_label", "Copy modifier"); + setText("popup_preview_label", "Hover preview"); + setText("popup_preview_hint", "Show the target overlay while the modifier key is held."); + setText("popup_safe_label", "Skip editable apps"); + setText("popup_safe_hint", "Avoid copying inside contenteditable editors and rich text surfaces."); + setText("popup_duration_label", "Feedback duration"); + setText("copy_history_limit_label", "Saved history items"); + setText("popup_append_hint", "Quick controls for copy behavior, preview safety, and recent history."); + setText("popup_history_title", "Recent copies"); + setText("clear_history", "Clear history"); + setText("open_options", "Open full settings"); + } + + function showStatus(message) { + context.elements.status.textContent = message; + clearTimeout(showStatus.timerId); + showStatus.timerId = setTimeout(function () { + context.elements.status.textContent = ""; + }, 1600); + } + + return { + t, + setText, + setSiteStatus, + applyMessages, + showStatus, + createHistoryChip, + formatRelativeTime, + formatAbsoluteTime, + normalizeSourceClass, + }; +} + +module.exports = { + createPopupUi, +}; diff --git a/src/shared/analytics.js b/src/shared/analytics.js new file mode 100644 index 0000000..a206e7c --- /dev/null +++ b/src/shared/analytics.js @@ -0,0 +1,8 @@ +const core = require("./core.js"); + +module.exports = { + DEFAULT_ANALYTICS: core.DEFAULT_ANALYTICS, + normalizeAnalytics: core.normalizeAnalytics, + recordAnalyticsEvent: core.recordAnalyticsEvent, + getTopDomainStats: core.getTopDomainStats, +}; diff --git a/shared.js b/src/shared/core.js similarity index 92% rename from shared.js rename to src/shared/core.js index 35fd15e..755d756 100644 --- a/shared.js +++ b/src/shared/core.js @@ -1,11 +1,4 @@ -(function (root, factory) { - var api = factory(); - if (typeof module !== "undefined" && module.exports) { - module.exports = api; - } - root.CopyTextUtils = api; -})(typeof globalThis !== "undefined" ? globalThis : this, function () { - var DEFAULT_SETTINGS = { +var DEFAULT_SETTINGS = { metaKey: "Alt", excludedDomains: [], previewEnabled: true, @@ -570,52 +563,51 @@ } } - return { - DEFAULT_SETTINGS: DEFAULT_SETTINGS, - DEFAULT_ANALYTICS: DEFAULT_ANALYTICS, - SUPPORTED_META_KEYS: SUPPORTED_META_KEYS, - SUPPORTED_UI_LANGUAGES: SUPPORTED_UI_LANGUAGES, - UI_MESSAGES: VI_MESSAGES, - normalizeDomain: normalizeDomain, - normalizeExcludedDomains: normalizeExcludedDomains, - normalizeMetaKey: normalizeMetaKey, - normalizeToastDuration: normalizeToastDuration, - normalizeUiLanguage: normalizeUiLanguage, - normalizeCopyHistoryLimit: normalizeCopyHistoryLimit, - mergeSettings: mergeSettings, - isExcludedHost: isExcludedHost, - buildExcludeMatches: buildExcludeMatches, - getCopyMode: getCopyMode, - isPrimaryModifierPressed: isPrimaryModifierPressed, - isModifierKeyEvent: isModifierKeyEvent, - isEditableSurface: isEditableSurface, - toggleDomain: toggleDomain, - getHostnameFromUrl: getHostnameFromUrl, - getTextSnippet: getTextSnippet, - getLanguage: getLanguage, - translate: translate, - normalizeHistoryEntry: normalizeHistoryEntry, - pushHistoryEntry: pushHistoryEntry, - updateHistoryEntry: updateHistoryEntry, - deleteHistoryEntries: deleteHistoryEntries, - sortHistoryEntries: sortHistoryEntries, - groupHistoryEntries: groupHistoryEntries, - normalizeAnalytics: normalizeAnalytics, - recordAnalyticsEvent: recordAnalyticsEvent, - getTopDomainStats: getTopDomainStats, - filterHistoryEntries: filterHistoryEntries, - getHistoryHostOptions: getHistoryHostOptions, - sanitizeText: sanitizeText, - isExtensionContextValid: isExtensionContextValid, - isExtensionContextInvalidatedError: isExtensionContextInvalidatedError, - safeChromeAsync: safeChromeAsync, - safeStorageGet: safeStorageGet, - safeStorageSet: safeStorageSet, - safeTabsQuery: safeTabsQuery, - safeExecuteScript: safeExecuteScript, - safeSendMessage: safeSendMessage, - safeOpenOptionsPage: safeOpenOptionsPage, - addListenerSafely: addListenerSafely, - removeListenerSafely: removeListenerSafely, - }; -}); +module.exports = { + DEFAULT_SETTINGS: DEFAULT_SETTINGS, + DEFAULT_ANALYTICS: DEFAULT_ANALYTICS, + SUPPORTED_META_KEYS: SUPPORTED_META_KEYS, + SUPPORTED_UI_LANGUAGES: SUPPORTED_UI_LANGUAGES, + UI_MESSAGES: VI_MESSAGES, + normalizeDomain: normalizeDomain, + normalizeExcludedDomains: normalizeExcludedDomains, + normalizeMetaKey: normalizeMetaKey, + normalizeToastDuration: normalizeToastDuration, + normalizeUiLanguage: normalizeUiLanguage, + normalizeCopyHistoryLimit: normalizeCopyHistoryLimit, + mergeSettings: mergeSettings, + isExcludedHost: isExcludedHost, + buildExcludeMatches: buildExcludeMatches, + getCopyMode: getCopyMode, + isPrimaryModifierPressed: isPrimaryModifierPressed, + isModifierKeyEvent: isModifierKeyEvent, + isEditableSurface: isEditableSurface, + toggleDomain: toggleDomain, + getHostnameFromUrl: getHostnameFromUrl, + getTextSnippet: getTextSnippet, + getLanguage: getLanguage, + translate: translate, + normalizeHistoryEntry: normalizeHistoryEntry, + pushHistoryEntry: pushHistoryEntry, + updateHistoryEntry: updateHistoryEntry, + deleteHistoryEntries: deleteHistoryEntries, + sortHistoryEntries: sortHistoryEntries, + groupHistoryEntries: groupHistoryEntries, + normalizeAnalytics: normalizeAnalytics, + recordAnalyticsEvent: recordAnalyticsEvent, + getTopDomainStats: getTopDomainStats, + filterHistoryEntries: filterHistoryEntries, + getHistoryHostOptions: getHistoryHostOptions, + sanitizeText: sanitizeText, + isExtensionContextValid: isExtensionContextValid, + isExtensionContextInvalidatedError: isExtensionContextInvalidatedError, + safeChromeAsync: safeChromeAsync, + safeStorageGet: safeStorageGet, + safeStorageSet: safeStorageSet, + safeTabsQuery: safeTabsQuery, + safeExecuteScript: safeExecuteScript, + safeSendMessage: safeSendMessage, + safeOpenOptionsPage: safeOpenOptionsPage, + addListenerSafely: addListenerSafely, + removeListenerSafely: removeListenerSafely, +}; diff --git a/src/shared/history.js b/src/shared/history.js new file mode 100644 index 0000000..38ef9c5 --- /dev/null +++ b/src/shared/history.js @@ -0,0 +1,12 @@ +const core = require("./core.js"); + +module.exports = { + normalizeHistoryEntry: core.normalizeHistoryEntry, + pushHistoryEntry: core.pushHistoryEntry, + updateHistoryEntry: core.updateHistoryEntry, + deleteHistoryEntries: core.deleteHistoryEntries, + sortHistoryEntries: core.sortHistoryEntries, + groupHistoryEntries: core.groupHistoryEntries, + filterHistoryEntries: core.filterHistoryEntries, + getHistoryHostOptions: core.getHistoryHostOptions, +}; diff --git a/src/shared/i18n.js b/src/shared/i18n.js new file mode 100644 index 0000000..d060320 --- /dev/null +++ b/src/shared/i18n.js @@ -0,0 +1,7 @@ +const core = require("./core.js"); + +module.exports = { + UI_MESSAGES: core.UI_MESSAGES, + getLanguage: core.getLanguage, + translate: core.translate, +}; diff --git a/src/shared/index.js b/src/shared/index.js new file mode 100644 index 0000000..353fb9a --- /dev/null +++ b/src/shared/index.js @@ -0,0 +1,7 @@ +const core = require("./core.js"); + +if (typeof globalThis !== "undefined") { + globalThis.CopyTextUtils = core; +} + +module.exports = core; diff --git a/src/shared/runtime.js b/src/shared/runtime.js new file mode 100644 index 0000000..1b2ca6e --- /dev/null +++ b/src/shared/runtime.js @@ -0,0 +1,15 @@ +const core = require("./core.js"); + +module.exports = { + isExtensionContextValid: core.isExtensionContextValid, + isExtensionContextInvalidatedError: core.isExtensionContextInvalidatedError, + safeChromeAsync: core.safeChromeAsync, + safeStorageGet: core.safeStorageGet, + safeStorageSet: core.safeStorageSet, + safeTabsQuery: core.safeTabsQuery, + safeExecuteScript: core.safeExecuteScript, + safeSendMessage: core.safeSendMessage, + safeOpenOptionsPage: core.safeOpenOptionsPage, + addListenerSafely: core.addListenerSafely, + removeListenerSafely: core.removeListenerSafely, +}; diff --git a/src/shared/settings.js b/src/shared/settings.js new file mode 100644 index 0000000..8cca79c --- /dev/null +++ b/src/shared/settings.js @@ -0,0 +1,22 @@ +const core = require("./core.js"); + +module.exports = { + DEFAULT_SETTINGS: core.DEFAULT_SETTINGS, + SUPPORTED_META_KEYS: core.SUPPORTED_META_KEYS, + SUPPORTED_UI_LANGUAGES: core.SUPPORTED_UI_LANGUAGES, + normalizeDomain: core.normalizeDomain, + normalizeExcludedDomains: core.normalizeExcludedDomains, + normalizeMetaKey: core.normalizeMetaKey, + normalizeToastDuration: core.normalizeToastDuration, + normalizeUiLanguage: core.normalizeUiLanguage, + normalizeCopyHistoryLimit: core.normalizeCopyHistoryLimit, + mergeSettings: core.mergeSettings, + isExcludedHost: core.isExcludedHost, + buildExcludeMatches: core.buildExcludeMatches, + getCopyMode: core.getCopyMode, + isPrimaryModifierPressed: core.isPrimaryModifierPressed, + isModifierKeyEvent: core.isModifierKeyEvent, + isEditableSurface: core.isEditableSurface, + toggleDomain: core.toggleDomain, + getHostnameFromUrl: core.getHostnameFromUrl, +}; diff --git a/src/shared/text.js b/src/shared/text.js new file mode 100644 index 0000000..6d2a613 --- /dev/null +++ b/src/shared/text.js @@ -0,0 +1,6 @@ +const core = require("./core.js"); + +module.exports = { + sanitizeText: core.sanitizeText, + getTextSnippet: core.getTextSnippet, +}; diff --git a/tests/e2e/extension.spec.js b/tests/e2e/extension.spec.js new file mode 100644 index 0000000..05e34fb --- /dev/null +++ b/tests/e2e/extension.spec.js @@ -0,0 +1,260 @@ +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); +const os = require("node:os"); +const { test, expect, chromium } = require("@playwright/test"); + +const FIXTURE_ROOT = path.join(__dirname, "..", "..", "fixtures"); +const EXTENSION_ROOT = path.join(__dirname, "..", "..", "dist", "chrome"); +const HOST = "127.0.0.1"; +const PORT = 4173; +const BASE_URL = `http://${HOST}:${PORT}`; + +let server; +let context; +let extensionId; +let userDataDir; + +function contentTypeFor(filePath) { + const extension = path.extname(filePath).toLowerCase(); + switch (extension) { + case ".html": return "text/html; charset=utf-8"; + case ".css": return "text/css; charset=utf-8"; + case ".js": return "text/javascript; charset=utf-8"; + case ".json": return "application/json; charset=utf-8"; + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + default: return "text/plain; charset=utf-8"; + } +} + +async function startFixtureServer() { + server = http.createServer(function (request, response) { + const requestPath = new URL(request.url, BASE_URL).pathname; + const relativePath = decodeURIComponent(requestPath.replace(/^\/+/, "")); + const absolutePath = path.join(path.dirname(FIXTURE_ROOT), relativePath); + + if (!absolutePath.startsWith(path.dirname(FIXTURE_ROOT)) || !fs.existsSync(absolutePath)) { + response.statusCode = 404; + response.end("Not found"); + return; + } + + const stat = fs.statSync(absolutePath); + if (stat.isDirectory()) { + response.statusCode = 403; + response.end("Forbidden"); + return; + } + + response.statusCode = 200; + response.setHeader("Content-Type", contentTypeFor(absolutePath)); + response.end(fs.readFileSync(absolutePath)); + }); + + await new Promise(function (resolve) { + server.listen(PORT, HOST, resolve); + }); +} + +async function stopFixtureServer() { + if (!server) { + return; + } + + await new Promise(function (resolve) { + server.close(resolve); + }); + server = null; +} + +async function launchExtensionContext() { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "copy-text-extension-")); + context = await chromium.launchPersistentContext(userDataDir, { + executablePath: chromium.executablePath(), + headless: true, + args: [ + `--disable-extensions-except=${EXTENSION_ROOT}`, + `--load-extension=${EXTENSION_ROOT}`, + ], + }); + + await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: BASE_URL }); + + let serviceWorker = context.serviceWorkers()[0]; + if (!serviceWorker) { + serviceWorker = await context.waitForEvent("serviceworker"); + } + + extensionId = new URL(serviceWorker.url()).host; +} + +async function closeExtensionContext() { + if (context) { + await context.close(); + context = null; + } + + if (userDataDir) { + fs.rmSync(userDataDir, { recursive: true, force: true }); + userDataDir = null; + } +} + +async function openPage(relativePath) { + const page = await context.newPage(); + const consoleMessages = []; + page.on("console", function (message) { + consoleMessages.push(message.text()); + }); + await page.goto(`${BASE_URL}/${relativePath}`); + return { page, consoleMessages }; +} + +async function openExtensionPage(relativePath) { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/${relativePath}`); + return page; +} + +async function withFreshExtensionContext(callback) { + await closeExtensionContext(); + await launchExtensionContext(); + return callback(); +} + +async function readClipboard(page) { + return page.evaluate(async function () { + return navigator.clipboard.readText(); + }); +} + +async function altClick(target) { + await target.click({ modifiers: ["Alt"] }); +} + +test.beforeAll(async function () { + await startFixtureServer(); + await launchExtensionContext(); +}); + +test.afterAll(async function () { + await closeExtensionContext(); + await stopFixtureServer(); +}); + +test("copies basic paragraph text", async function () { + const { page } = await openPage("fixtures/basic-copy.html"); + await altClick(page.locator("#plain-text")); + await expect.poll(async function () { + return readClipboard(page); + }).toContain("Copying this paragraph should capture"); + await page.close(); +}); + +test("prefers the selected text over the whole element", async function () { + const { page } = await openPage("fixtures/selection-copy.html"); + await page.evaluate(function () { + const paragraph = document.querySelectorAll(".card p")[0]; + const textNode = paragraph.firstChild; + const range = document.createRange(); + const text = textNode.textContent; + const start = text.indexOf("few words"); + range.setStart(textNode, start); + range.setEnd(textNode, start + "few words".length); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + const popupPage = await openExtensionPage("popup.html"); + await popupPage.evaluate(async function () { + const tabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const targetTab = tabs.find(function (tab) { + return typeof tab.url === "string" && tab.url.includes("/fixtures/selection-copy.html"); + }); + if (!targetTab || !targetTab.id) { + throw new Error("Fixture tab not found for selection test."); + } + await chrome.tabs.sendMessage(targetTab.id, { + type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", + }); + }); + await popupPage.close(); + await expect.poll(async function () { + return readClipboard(page); + }).toBe("few words"); + await page.close(); +}); + +test("copies hovered paragraph through the shortcut message path", async function () { + const { page } = await openPage("fixtures/keyboard-shortcut.html"); + await page.locator("section.card >> text=Hover this paragraph").hover(); + const popupPage = await openExtensionPage("popup.html"); + await popupPage.evaluate(async function () { + const tabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const targetTab = tabs.find(function (tab) { + return typeof tab.url === "string" && tab.url.includes("/fixtures/keyboard-shortcut.html"); + }); + if (!targetTab || !targetTab.id) { + throw new Error("Fixture tab not found for shortcut test."); + } + await chrome.tabs.sendMessage(targetTab.id, { + type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", + }); + }); + await popupPage.close(); + await expect.poll(async function () { + return readClipboard(page); + }).toContain("Hover this paragraph"); + await page.close(); +}); + +test("saves popup settings and excluded domains roundtrip", async function () { + const popupPage = await openExtensionPage("popup.html"); + await popupPage.selectOption("#popup_meta_key", "Ctrl"); + await popupPage.locator("#popup_copy_history_limit").fill("7"); + await popupPage.locator("#popup_preview_enabled").uncheck(); + await popupPage.waitForTimeout(250); + await popupPage.close(); + + const optionsPage = await openExtensionPage("options.html"); + await expect(optionsPage.locator("#meta_key")).toHaveValue("Ctrl"); + await expect(optionsPage.locator("#copy_history_limit")).toHaveValue("7"); + await expect(optionsPage.locator("#preview_enabled")).not.toBeChecked(); + + await optionsPage.locator("#tab_sites").click(); + await optionsPage.locator("#domain_input").fill(HOST); + await optionsPage.locator("#add_domain_button").click(); + await expect(optionsPage.locator("#excluded_domains_list")).toContainText(HOST); + await optionsPage.close(); +}); + +test("blocks copy on excluded domain", async function () { + const { page } = await openPage("fixtures/basic-copy.html"); + await page.evaluate(async function () { + await navigator.clipboard.writeText("unchanged"); + }); + await altClick(page.locator("#plain-text")); + await expect.poll(async function () { + return readClipboard(page); + }).toBe("unchanged"); + await page.close(); +}); + +test("extension reload plus page refresh does not break copy or spam invalidation errors", async function () { + await withFreshExtensionContext(async function () { + const { page, consoleMessages } = await openPage("fixtures/basic-copy.html"); + await page.reload(); + await altClick(page.locator("#plain-text")); + await expect.poll(async function () { + return readClipboard(page); + }).toContain("Copying this paragraph should capture"); + + expect(consoleMessages.filter(function (message) { + return /Extension context invalidated/i.test(message); + })).toEqual([]); + + await page.close(); + }); +}); diff --git a/tests/release-utils.test.js b/tests/release-utils.test.js index 66304aa..6fa8dd1 100644 --- a/tests/release-utils.test.js +++ b/tests/release-utils.test.js @@ -40,9 +40,12 @@ test("getReleaseEntries includes shipped files and excludes repo-only files", fu const relativePaths = entries.map(function (entry) { return entry.relativePath; }); + const expectedOutputPaths = releaseUtils.getExpectedChromeOutputPaths(); assert.ok(relativePaths.includes("manifest.json")); - assert.ok(relativePaths.includes("background.js")); + assert.ok(relativePaths.includes("popup.css")); + assert.ok(expectedOutputPaths.includes("background.js")); + assert.ok(expectedOutputPaths.includes("shared.js")); assert.ok(relativePaths.some(function (entry) { return entry.startsWith("_locales/") && entry.endsWith("/messages.json"); })); diff --git a/tests/shared.test.js b/tests/shared.test.js index 6a0577f..0512835 100644 --- a/tests/shared.test.js +++ b/tests/shared.test.js @@ -1,6 +1,6 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const utils = require("../shared.js"); +const utils = require("../src/shared/core.js"); test("normalizeDomain strips protocol, paths, and leading dots", function () { assert.equal(utils.normalizeDomain("https://docs.google.com/document/d/123"), "docs.google.com"); From 400a2afd509e6bfbf9338c7049fe792b9d93e0da Mon Sep 17 00:00:00 2001 From: VET Date: Tue, 19 May 2026 09:38:12 +0700 Subject: [PATCH 04/12] ref(extension): Split content and options entrypoints into focused modules Move the remaining hot-path logic out of the oversized content and options entrypoints so src/content/bootstrap.js and src/options/index.js become orchestration layers instead of carrying extraction, overlay, history, and settings internals directly. This keeps behavior locked by existing release verification while lowering the cost of future changes. Constraint: Behavior must continue to match the frozen baseline commit c911c73 Rejected: Leave bootstrap and options index as monoliths | future changes would keep compounding maintenance cost Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Continue new work in the extracted src/content and src/options modules rather than rebuilding logic in the entrypoints Tested: npm run verify:release Not-tested: Extra exploratory scenarios outside the automated smoke suite Co-Authored-By: Claude --- src/content/bootstrap.js | 1855 ++++----------------------------- src/content/events.js | 225 ++++ src/content/helpers.js | 1292 +++++++++++++++++++++++ src/options/analytics-view.js | 61 ++ src/options/domains.js | 78 ++ src/options/history-view.js | 402 +++++++ src/options/index.js | 880 +++------------- src/options/settings-form.js | 64 ++ 8 files changed, 2482 insertions(+), 2375 deletions(-) create mode 100644 src/content/events.js create mode 100644 src/content/helpers.js create mode 100644 src/options/analytics-view.js create mode 100644 src/options/domains.js create mode 100644 src/options/history-view.js create mode 100644 src/options/settings-form.js diff --git a/src/content/bootstrap.js b/src/content/bootstrap.js index 40b6765..de8df93 100644 --- a/src/content/bootstrap.js +++ b/src/content/bootstrap.js @@ -1,1674 +1,245 @@ (function () { - var previousState = globalThis.__copyTextWithAltClickContentScriptState; - if (previousState && typeof previousState.dispose === "function") { - previousState.dispose(); - } - - var utils = globalThis.CopyTextUtils; - if (!utils) { - console.error("CopyTextUtils is not available."); - return; - } - - var settings = utils.mergeSettings(); - var overlayState; - var hoverState = { - hoveredElement: null, - previewModifierActive: false, - previewAnimationFrame: 0, - pointerPageX: null, - pointerPageY: null, - pointerClientX: null, - pointerClientY: null, - scopeLevel: 0, - scopeAnchorClientX: null, - scopeAnchorClientY: null, - lastRenderedTarget: null, - }; - var suppressNativeCopyTracking = false; - var extensionContextInvalidated = !utils.isExtensionContextValid(); - var disposed = false; - var scriptState = { dispose: dispose }; - - globalThis.__copyTextWithAltClickContentScriptState = scriptState; - - updateSettings(); - attachExtensionListeners(); - attachDomListeners(); - - function onStorageChanged(changes, areaName) { - if (!isExtensionUsable()) { - return; - } - - if (areaName == "sync") { - updateSettings(); - } - } - - function onRuntimeMessage(message) { - if (!isExtensionUsable()) { - return; - } - - if (!message || message.type != "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT") { - return; - } - - if (isCurrentHostExcluded() || !settings.keyboardShortcutEnabled) { - if (isCurrentHostExcluded()) { - saveAnalyticsEvent({ - type: "blockedExcluded", - hostname: window.location.hostname, - toastKind: "status", - }); - } - return; - } - - var shortcutTarget = resolveShortcutTarget(); - if (!shortcutTarget) { - saveAnalyticsEvent({ - toastKind: "status", - hostname: window.location.hostname, - }); - showStatusToast(t("shortcut_unavailable", "No hovered or focused target to copy.")); - return; - } - - if (shouldIgnoreElement(shortcutTarget)) { - saveAnalyticsEvent({ - type: "editableSkipped", - hostname: window.location.hostname, - toastKind: "status", - }); - showStatusToast(t("unsupported_surface_status", "Editing surface skipped")); - return; - } - - copyCommand(shortcutTarget, "shortcut", { - preferSelection: true, - }).catch(function (error) { - if (handleExtensionContextError(error)) { - return; - } - console.error("Shortcut copy failed.", error); + const { createContentHelpers } = require("./helpers.js"); + const { createContentEvents } = require("./events.js"); + + const previousState = globalThis.__copyTextWithAltClickContentScriptState; + if (previousState && typeof previousState.dispose === "function") { + previousState.dispose(); + } + + const utils = globalThis.CopyTextUtils; + if (!utils) { + console.error("CopyTextUtils is not available."); + return; + } + + const state = { + settings: utils.mergeSettings(), + overlayState: null, + hoverState: { + hoveredElement: null, + previewModifierActive: false, + previewAnimationFrame: 0, + pointerPageX: null, + pointerPageY: null, + pointerClientX: null, + pointerClientY: null, + scopeLevel: 0, + scopeAnchorClientX: null, + scopeAnchorClientY: null, + lastRenderedTarget: null, + }, + suppressNativeCopyTracking: false, + extensionContextInvalidated: !utils.isExtensionContextValid(), + disposed: false, + scriptState: null, + }; + + function isExtensionUsable() { + return !state.disposed && !state.extensionContextInvalidated && utils.isExtensionContextValid(); + } + + function isCurrentHostExcluded() { + return utils.isExcludedHost(window.location.hostname, state.settings.excludedDomains); + } + + function t(key, fallback) { + return utils.translate(state.settings, key, fallback, chrome.i18n && chrome.i18n.getUILanguage ? chrome.i18n.getUILanguage() : ""); + } + + const context = { + utils, + state, + isExtensionUsable, + isCurrentHostExcluded, + t, + handleExtensionContextError, + }; + + const helpers = createContentHelpers(context); + context.helpers = helpers; + const events = createContentEvents(context); + state.scriptState = { dispose: dispose }; + globalThis.__copyTextWithAltClickContentScriptState = state.scriptState; + + updateSettings(); + attachExtensionListeners(); + attachDomListeners(); + + function onStorageChanged(changes, areaName) { + if (!isExtensionUsable()) { + return; + } + + if (areaName == "sync") { + updateSettings(); + } + } + + function onRuntimeMessage(message) { + if (!isExtensionUsable()) { + return; + } + + if (!message || message.type != "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT") { + return; + } + + if (isCurrentHostExcluded() || !state.settings.keyboardShortcutEnabled) { + if (isCurrentHostExcluded()) { + helpers.saveAnalyticsEvent({ + type: "blockedExcluded", + hostname: window.location.hostname, + toastKind: "status", }); - } - - function attachExtensionListeners() { - if (extensionContextInvalidated) { - return; - } - - try { - utils.addListenerSafely(chrome.storage.onChanged, onStorageChanged); - } catch (error) { - handleExtensionContextError(error); - } - - try { - utils.addListenerSafely(chrome.runtime.onMessage, onRuntimeMessage); - } catch (error) { - handleExtensionContextError(error); - } - } - - function removeExtensionListeners() { - try { - utils.removeListenerSafely(chrome.storage.onChanged, onStorageChanged); - } catch (error) { - // Ignore invalidated contexts during teardown. - } - - try { - utils.removeListenerSafely(chrome.runtime.onMessage, onRuntimeMessage); - } catch (error) { - // Ignore invalidated contexts during teardown. - } - } - - function attachDomListeners() { - document.addEventListener("click", handleClick, false); - document.addEventListener("mousemove", handleMouseMove, true); - document.addEventListener("mouseover", handleMouseOver, true); - document.addEventListener("mouseout", handleMouseOut, true); - document.addEventListener("copy", handleNativeCopy, true); - document.addEventListener("keydown", handleModifierChange, true); - document.addEventListener("keyup", handleModifierChange, true); - document.addEventListener("wheel", handleWheel, { capture: true, passive: false }); - document.addEventListener("scroll", handleViewportChange, true); - document.addEventListener("visibilitychange", handleVisibilityChange, true); - window.addEventListener("resize", handleViewportChange); - } - - function removeDomListeners() { - document.removeEventListener("click", handleClick, false); - document.removeEventListener("mousemove", handleMouseMove, true); - document.removeEventListener("mouseover", handleMouseOver, true); - document.removeEventListener("mouseout", handleMouseOut, true); - document.removeEventListener("copy", handleNativeCopy, true); - document.removeEventListener("keydown", handleModifierChange, true); - document.removeEventListener("keyup", handleModifierChange, true); - document.removeEventListener("wheel", handleWheel, true); - document.removeEventListener("scroll", handleViewportChange, true); - document.removeEventListener("visibilitychange", handleVisibilityChange, true); - window.removeEventListener("resize", handleViewportChange); - } - - function isExtensionUsable() { - return !disposed && !extensionContextInvalidated && utils.isExtensionContextValid(); - } - - function handleExtensionContextError(error) { - if (!utils.isExtensionContextInvalidatedError(error)) { - return false; - } - - dispose(); - return true; - } - - function dispose() { - if (disposed) { - return; - } - - disposed = true; - extensionContextInvalidated = true; - - if (hoverState.previewAnimationFrame) { - cancelAnimationFrame(hoverState.previewAnimationFrame); - hoverState.previewAnimationFrame = 0; - } - - removeExtensionListeners(); - removeDomListeners(); - hidePreview(); - - if (globalThis.__copyTextWithAltClickContentScriptState === scriptState) { - delete globalThis.__copyTextWithAltClickContentScriptState; - } - } - - function handleClick(event) { - if (!isExtensionUsable()) { - return; - } - - var copyMode = utils.getCopyMode(settings.metaKey, event); - if (!copyMode || isCurrentHostExcluded()) { - if (copyMode && isCurrentHostExcluded()) { - saveAnalyticsEvent({ - type: "blockedExcluded", - hostname: window.location.hostname, - }); - } - return; - } - - if (shouldIgnoreElement(event.target)) { - saveAnalyticsEvent({ - type: "editableSkipped", - hostname: window.location.hostname, - }); - return; - } - - var targetToCopy = hoverState.lastRenderedTarget; - - syncPointerState(event); - - if (targetToCopy) { - executePrecisionCopy(targetToCopy, "click").catch(function (error) { - if (handleExtensionContextError(error)) { - return; - } - console.error("Copy failed.", error); - }); - } else { - var composedTarget = event.composedPath ? event.composedPath()[0] : event.target; - var deepTarget = getElementNode(pierceShadowDOM(composedTarget, event.clientX, event.clientY)); - copyCommand(deepTarget, "click", { - clientX: event.clientX, - clientY: event.clientY, - preferSelection: true, - }).catch(function (error) { - if (handleExtensionContextError(error)) { - return; - } - console.error("Copy failed.", error); - }); - } - - event.preventDefault(); - } - - function handleMouseMove(event) { - if (!isExtensionUsable()) { - return; - } - - // Fast path: only track pointer coordinates when no modifier is held - if (!utils.isPrimaryModifierPressed(settings.metaKey, event)) { - hoverState.pointerClientX = event.clientX; - hoverState.pointerClientY = event.clientY; - hoverState.pointerPageX = event.pageX; - hoverState.pointerPageY = event.pageY; - hoverState.previewModifierActive = false; - return; - } - - syncPointerState(event); - var composedTarget = event.composedPath ? event.composedPath()[0] : event.target; - hoverState.hoveredElement = getElementNode(pierceShadowDOM(composedTarget, event.clientX, event.clientY)); - hoverState.previewModifierActive = true; - hoverState.scopeLevel = 0; - - if (shouldShowPreview()) { - schedulePreviewUpdate(); - } else { - hidePreview(); - } - } - - function handleMouseOver(event) { - if (!isExtensionUsable()) { - return; - } - - syncPointerState(event); - hoverState.hoveredElement = getElementNode(event.target); - hoverState.previewModifierActive = utils.isPrimaryModifierPressed(settings.metaKey, event); - - if (shouldShowPreview()) { - schedulePreviewUpdate(); - } - } - - function handleMouseOut(event) { - if (!isExtensionUsable()) { - return; - } - - if (!event.relatedTarget) { - hoverState.hoveredElement = null; - hidePreview(); - return; - } - - hoverState.hoveredElement = getElementNode(event.relatedTarget); - - if (shouldShowPreview()) { - schedulePreviewUpdate(); - } else if (!hoverState.previewModifierActive) { - hidePreview(); - } - } - - function handleNativeCopy() { - if (!isExtensionUsable()) { - return; - } - - if (suppressNativeCopyTracking || isCurrentHostExcluded()) { - return; - } - - var copiedText = getNativeCopiedText(); - if (!copiedText) { - return; - } - - saveAnalyticsEvent({ - type: "nativeCopy", - hostname: window.location.hostname, - }); - saveHistory(copiedText, "copied", "native"); - } - - function handleModifierChange(event) { - if (!isExtensionUsable()) { - return; - } - - if (!utils.isModifierKeyEvent(event)) { - return; - } - - hoverState.previewModifierActive = utils.isPrimaryModifierPressed(settings.metaKey, event); - - if (!hoverState.previewModifierActive) { - hoverState.scopeLevel = 0; - } - - if (!hoverState.hoveredElement && hoverState.pointerClientX !== null && hoverState.pointerClientY !== null) { - var pointElement = document.elementFromPoint(hoverState.pointerClientX, hoverState.pointerClientY); - hoverState.hoveredElement = getElementNode(pierceShadowDOM(pointElement, hoverState.pointerClientX, hoverState.pointerClientY)); - } - - if (shouldShowPreview()) { - schedulePreviewUpdate(); - } else { - hidePreview(); - } - } - - function handleViewportChange() { - if (!isExtensionUsable()) { - return; - } - - if (shouldShowPreview()) { - schedulePreviewUpdate(); - } - } - - function handleVisibilityChange() { - if (!isExtensionUsable()) { - return; - } - - if (document.hidden) { - hidePreview(); - } - } - - function handleWheel(event) { - if (!isExtensionUsable()) { - return; - } - - if (!hoverState.previewModifierActive || !shouldShowPreview()) { - return; - } - - event.preventDefault(); - - var delta = event.deltaY < 0 ? 1 : -1; - var nextLevel = Math.max(0, Math.min(3, hoverState.scopeLevel + delta)); - if (nextLevel === hoverState.scopeLevel) { - return; - } - - hoverState.scopeLevel = nextLevel; - hoverState.scopeAnchorClientX = hoverState.pointerClientX; - hoverState.scopeAnchorClientY = hoverState.pointerClientY; - schedulePreviewUpdate(); - } - - function pierceShadowDOM(element, x, y) { - if (!element) { - return element; - } - var root = element.shadowRoot; - if (!root) { - return element; - } - var deeper = root.elementFromPoint(x, y); - if (!deeper || deeper === element) { - return element; - } - return pierceShadowDOM(deeper, x, y); - } - - function syncPointerState(event) { - hoverState.pointerPageX = event.pageX; - hoverState.pointerPageY = event.pageY; - hoverState.pointerClientX = event.clientX; - hoverState.pointerClientY = event.clientY; - } - - - - var SCOPE_WORD = 0; - var SCOPE_SENTENCE = 1; - var SCOPE_PARAGRAPH = 2; - var SCOPE_CONTAINER = 3; - - function resolveScopedTarget(sourceNode, clientX, clientY, level) { - if (level <= SCOPE_WORD) { - return null; - } - - var caretRange = getCaretRangeAtPoint(clientX, clientY); - if (!caretRange) { - return null; - } - - var textNode = caretRange.startContainer; - if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { - return null; - } - - var fullText = textNode.textContent || ""; - - if (level === SCOPE_SENTENCE) { - var sentenceRange = expandToSentence(textNode, caretRange.startOffset); - if (sentenceRange) { - return { - kind: "scope", - node: textNode, - range: sentenceRange, - rect: getRangeBoundingRect(sentenceRange), - text: sentenceRange.toString().trim(), - }; - } - } - - if (level === SCOPE_PARAGRAPH) { - var paragraphElement = getElementNode(textNode); - while (paragraphElement && paragraphElement !== document.body) { - var display = window.getComputedStyle ? window.getComputedStyle(paragraphElement).display : ""; - if (display === "block" || display === "list-item" || display === "flex" || paragraphElement.nodeName === "P" || paragraphElement.nodeName === "LI") { - break; - } - paragraphElement = paragraphElement.parentElement; - } - if (paragraphElement && paragraphElement !== document.body) { - return createElementTarget(paragraphElement); - } - } - - if (level === SCOPE_CONTAINER) { - var container = getElementNode(textNode); - if (container && container.parentElement && container.parentElement !== document.body && container.parentElement !== document.documentElement) { - return createElementTarget(container.parentElement); - } - } - - return null; - } - - function expandToSentence(textNode, offset) { - var text = textNode.textContent || ""; - if (!text.trim()) { - return null; - } - - var sentenceBreaks = /[.!?。!?]+[\s]*/g; - var sentences = []; - var lastEnd = 0; - var match; - while ((match = sentenceBreaks.exec(text)) !== null) { - sentences.push({ start: lastEnd, end: match.index + match[0].length }); - lastEnd = match.index + match[0].length; - } - if (lastEnd < text.length) { - sentences.push({ start: lastEnd, end: text.length }); - } - - if (sentences.length === 0) { - sentences.push({ start: 0, end: text.length }); - } - - var target = sentences[0]; - for (var i = 0; i < sentences.length; i++) { - if (offset >= sentences[i].start && offset <= sentences[i].end) { - target = sentences[i]; - break; - } - } - - var range = document.createRange(); - range.setStart(textNode, target.start); - range.setEnd(textNode, Math.min(target.end, text.length)); - return range; - } - - function updateSettings() { - if (!isExtensionUsable()) { - return; - } - - utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS).then(function (items) { - if (!isExtensionUsable()) { - return; - } - - settings = utils.mergeSettings(items); - - if (isCurrentHostExcluded() || !hoverState.previewModifierActive) { - hidePreview(); - } else if (shouldShowPreview()) { - schedulePreviewUpdate(); - } - }).catch(function (error) { - if (handleExtensionContextError(error)) { - return; - } - - console.warn("Updating settings failed.", error); - }); - } - - function resolvePrecisionTarget(sourceNode, options) { - var context = options || {}; - var sourceElement = getElementNode(sourceNode); - var clientX = Number.isFinite(context.clientX) ? context.clientX : hoverState.pointerClientX; - var clientY = Number.isFinite(context.clientY) ? context.clientY : hoverState.pointerClientY; - - if (context.preferSelection !== false) { - var selectionTarget = getSelectionTarget(sourceElement, clientX, clientY); - if (selectionTarget) { - return selectionTarget; - } - } - - // Scope expansion takes priority when active - var scopeClientX = hoverState.scopeAnchorClientX !== null ? hoverState.scopeAnchorClientX : clientX; - var scopeClientY = hoverState.scopeAnchorClientY !== null ? hoverState.scopeAnchorClientY : clientY; - if (hoverState.scopeLevel > SCOPE_WORD) { - var scopedTarget = resolveScopedTarget(sourceElement, scopeClientX, scopeClientY, hoverState.scopeLevel); - if (scopedTarget) { - return scopedTarget; - } - } - - var deepTextTarget = getDeepTextTarget(clientX, clientY); - var fallbackElement = getDeepElementTarget(sourceElement, clientX, clientY); - - var preliminaryTarget = deepTextTarget || createElementTarget(fallbackElement); - if (!preliminaryTarget) { - return null; - } - - var context = resolveExtractionContext(preliminaryTarget); - if (!context) { - return preliminaryTarget; - } - - switch (context.kind) { - case "selection": - case "scope": - return context; - case "table": - return { kind: "table", table: context.table, rect: context.table.getBoundingClientRect(), node: context.table }; - case "code": - return { kind: "code", container: context.container, rect: context.container.getBoundingClientRect(), node: context.container }; - case "list": - return { kind: "list", container: context.container, rect: context.container.getBoundingClientRect(), node: context.container }; - case "link": - return { kind: "link", anchor: context.anchor, rect: context.anchor.getBoundingClientRect(), node: context.anchor }; - case "image": - return { kind: "image", element: context.element, rect: context.element.getBoundingClientRect(), node: context.element }; - case "control": - return { kind: "control", element: context.element, rect: context.element.getBoundingClientRect(), node: context.element }; - case "text": - var rect = null; - if (context.node && context.node.nodeType === Node.TEXT_NODE) { - var textRange = document.createRange(); - textRange.selectNodeContents(context.node); - rect = getRangeBoundingRect(textRange); - } else if (context.node && context.node.getBoundingClientRect) { - rect = context.node.getBoundingClientRect(); - } - return { kind: "text", node: context.node, rect: rect || preliminaryTarget.rect }; - default: - return preliminaryTarget; - } - } - - function getSelectionTarget(sourceElement, clientX, clientY) { - var selection = window.getSelection ? window.getSelection() : null; - if (selection && selection.rangeCount && !selection.isCollapsed) { - var selectionText = String(selection.toString() || "").trim(); - if (selectionText) { - var range = selection.getRangeAt(0).cloneRange(); - var rect = getRangeBoundingRect(range); - var commonNode = selection.anchorNode || range.commonAncestorContainer; - if ((!Number.isFinite(clientX) || !Number.isFinite(clientY)) || isPointInsideRect(rect, clientX, clientY) || (sourceElement && commonNode && sourceElement.contains(getElementNode(commonNode)))) { - return { - kind: "selection", - range: range, - rect: rect, - text: selectionText, - node: commonNode, - }; - } - } - } - - var activeElement = document.activeElement; - if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { - var start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; - var end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; - if (end > start) { - var selectedText = String(activeElement.value || "").slice(start, end).trim(); - if (selectedText && (!sourceElement || sourceElement === activeElement || activeElement.contains(sourceElement))) { - return { - kind: "selection", - rect: activeElement.getBoundingClientRect(), - text: selectedText, - node: activeElement, - }; - } - } - } - - return null; - } - - function getDeepTextTarget(clientX, clientY) { - if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) { - return null; - } - - var range = getCaretRangeAtPoint(clientX, clientY); - if (!range) { - return null; - } - - var node = range.startContainer; - if (node && node.nodeType == Node.TEXT_NODE && String(node.textContent || "").trim() && isNodeVisible(node)) { - var textRange = document.createRange(); - textRange.selectNodeContents(node); - return { - kind: "text", - node: node, - rect: getRangeBoundingRect(textRange), - }; - } - - return null; - } - - function getDeepElementTarget(sourceElement, clientX, clientY) { - var element = Number.isFinite(clientX) && Number.isFinite(clientY) - ? pierceShadowDOM(document.elementFromPoint(clientX, clientY), clientX, clientY) - : sourceElement; - return getClosestMeaningfulElement(element || sourceElement); - } - - function getCaretRangeAtPoint(clientX, clientY) { - if (document.caretPositionFromPoint) { - var position = document.caretPositionFromPoint(clientX, clientY); - if (position && position.offsetNode) { - var range = document.createRange(); - range.setStart(position.offsetNode, position.offset); - range.setEnd(position.offsetNode, position.offset); - return range; - } - } - - if (document.caretRangeFromPoint) { - return document.caretRangeFromPoint(clientX, clientY); - } - - return null; - } - - function getClosestMeaningfulElement(element) { - var current = getElementNode(element); - while (current && current !== document.body && current !== document.documentElement) { - if (isNodeVisible(current) && hasMeaningfulText(current)) { - return current; - } - current = current.parentElement; - } - return getElementNode(element); - } - - function createElementTarget(element) { - if (!element || !isNodeVisible(element)) { - return null; - } - - return { - kind: "element", - node: element, - rect: element.getBoundingClientRect(), - }; - } - - function hasMeaningfulText(node) { - if (!node) { - return false; - } - - if (node.nodeType == Node.TEXT_NODE) { - return !!String(node.textContent || "").trim(); - } - - var tagName = String(node.nodeName || "").toUpperCase(); - if (tagName == "IMG" || tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "SELECT") { - return true; - } - - return !!collectVisibleText(node).trim(); - } - - function isNodeVisible(node) { - var current = node.nodeType == Node.TEXT_NODE ? node.parentElement : getElementNode(node); - while (current && current !== document.documentElement) { - if (current.getAttribute && current.getAttribute("aria-hidden") == "true") { - return false; - } - - var style = window.getComputedStyle ? window.getComputedStyle(current) : null; - if (style && (style.display == "none" || style.visibility == "hidden" || Number(style.opacity) === 0)) { - return false; - } - - current = current.parentElement; - } - - return true; - } - - function getRangeBoundingRect(range) { - if (!range) { - return null; - } - - var rect = range.getBoundingClientRect(); - if (rect && rect.width > 0 && rect.height > 0) { - return rect; - } - - var rects = range.getClientRects ? Array.from(range.getClientRects()) : []; - if (!rects.length) { - return rect; - } - - return rects.reduce(function (acc, current) { - if (!acc) { - return current; - } - - var left = Math.min(acc.left, current.left); - var top = Math.min(acc.top, current.top); - var right = Math.max(acc.right, current.right); - var bottom = Math.max(acc.bottom, current.bottom); - return { - left: left, - top: top, - right: right, - bottom: bottom, - width: right - left, - height: bottom - top, - }; - }, null); - } - - function isPointInsideRect(rect, clientX, clientY) { - if (!rect || !Number.isFinite(clientX) || !Number.isFinite(clientY)) { - return false; - } - return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom; - } - - function isCurrentHostExcluded() { - return utils.isExcludedHost(window.location.hostname, settings.excludedDomains); - } - - function t(key, fallback) { - return utils.translate(settings, key, fallback, chrome.i18n && chrome.i18n.getUILanguage ? chrome.i18n.getUILanguage() : ""); - } - - function shouldIgnoreElement(node) { - return settings.avoidEditable && utils.isEditableSurface(getElementNode(node)); - } - - function shouldShowPreview() { - return settings.previewEnabled - && hoverState.previewModifierActive - && !isCurrentHostExcluded() - && !!hoverState.hoveredElement - && hoverState.hoveredElement.isConnected - && !shouldIgnoreElement(hoverState.hoveredElement); - } - - function schedulePreviewUpdate() { - if (!shouldShowPreview()) { - hidePreview(); - return; - } - - if (hoverState.previewAnimationFrame) { - return; - } - - hoverState.previewAnimationFrame = window.requestAnimationFrame(function () { - hoverState.previewAnimationFrame = 0; - renderPreview(); - }); - } - - function renderPreview() { - if (!shouldShowPreview()) { - hidePreview(); - return; - } - - var precisionTarget = resolvePrecisionTarget(hoverState.hoveredElement, { - clientX: hoverState.pointerClientX, - clientY: hoverState.pointerClientY, - preferSelection: true, - }); - if (!precisionTarget || !precisionTarget.rect) { - hidePreview(); - return; - } - - var rect = precisionTarget.rect; - if (!hasRenderableRect(rect)) { - hidePreview(); - return; - } - - hoverState.lastRenderedTarget = precisionTarget; - - var state = getOverlayState(); - positionOverlayBox(state.preview, rect, 2); - state.preview.classList.add("visible"); - - // Scope level badge - var scopeLabels = ["", "Sentence", "Paragraph", "Container"]; - if (hoverState.scopeLevel > 0 && hoverState.scopeLevel < scopeLabels.length) { - state.scopeBadge.textContent = scopeLabels[hoverState.scopeLevel]; - state.scopeBadge.style.display = "block"; - var previewTop = parseFloat(state.preview.style.top) || 0; - var previewLeft = parseFloat(state.preview.style.left) || 0; - state.scopeBadge.style.top = (previewTop - 22) + "px"; - state.scopeBadge.style.left = previewLeft + "px"; - } else { - state.scopeBadge.style.display = "none"; - } - - // Large selection warning - var textLength = getText(precisionTarget).length; - if (textLength > 3000) { - state.warnBadge.textContent = "⚠ " + Math.round(textLength / 1000) + "k chars"; - state.warnBadge.style.display = "block"; - var pTop = parseFloat(state.preview.style.top) || 0; - var pLeft = parseFloat(state.preview.style.left) || 0; - var pWidth = parseFloat(state.preview.style.width) || 0; - state.warnBadge.style.top = (pTop - 22) + "px"; - state.warnBadge.style.left = (pLeft + pWidth - 80) + "px"; - } else { - state.warnBadge.style.display = "none"; - } - } - - function hidePreview() { - if (hoverState.previewAnimationFrame) { - window.cancelAnimationFrame(hoverState.previewAnimationFrame); - hoverState.previewAnimationFrame = 0; - } - - if (!overlayState) { - hoverState.lastRenderedTarget = null; - return; - } - - hoverState.lastRenderedTarget = null; - overlayState.preview.classList.remove("visible"); - overlayState.scopeBadge.style.display = "none"; - overlayState.warnBadge.style.display = "none"; - } - - function getText(target) { - var context = resolveExtractionContext(target); - if (!context) { - return ""; - } - - var raw; - switch (context.kind) { - case "selection": - raw = context.text; - break; - case "scope": - raw = context.text || collectVisibleText(context.node).trim(); - break; - case "table": - raw = extractTableAsTsv(context.table); - break; - case "code": - raw = extractCodeText(context.container); - break; - case "list": - raw = extractListAsText(context.container); - break; - case "image": - raw = getImageText(context.element); - break; - case "link": - raw = "[" + (collectVisibleText(context.anchor).trim() || context.anchor.href) + "](" + context.anchor.href + ")"; - break; - case "control": - raw = getPlainText(context.element).trim(); - break; - default: - raw = collectVisibleText(context.node).trim(); - } - - return sanitizeText(raw); - } - - function resolveExtractionContext(target) { - if (!target) { - return null; - } - - if (target.kind == "selection") { - return target; - } - - if (target.kind == "scope") { - return target; - } - - var node = target.node || target.element || getElementNode(target); - var element = getElementNode(node); - if (!element) { - return null; - } - - if (element.nodeName.toUpperCase() == "IMG") { - return { kind: "image", element: element }; - } - - var table = typeof element.closest == "function" ? element.closest("table") : null; - if (table) { - return { kind: "table", table: table }; - } - - var codeContainer = typeof element.closest == "function" ? element.closest("pre, code") : null; - if (codeContainer) { - return { kind: "code", container: codeContainer }; - } - - var listContainer = typeof element.closest == "function" ? element.closest("ul, ol") : null; - if (listContainer) { - return { kind: "list", container: listContainer }; - } - - var anchor = typeof element.closest == "function" ? element.closest("a[href]") : null; - if (anchor) { - return { kind: "link", anchor: anchor }; - } - - var tagName = element.nodeName.toUpperCase(); - if (tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "SELECT") { - return { kind: "control", element: element }; - } - - return { kind: "text", node: node }; - } - - function getExtractionNode(node) { - var element = getElementNode(node); - if (!element) { - return null; - } - - return element; - } - - function getElementNode(node) { - if (!node) { - return null; - } - - if (node.nodeType == Node.TEXT_NODE) { - return node.parentNode; - } - - return node.nodeType == Node.ELEMENT_NODE ? node : null; - } - - function getImageText(node) { - return node.getAttribute("src") || node.getAttribute("alt") || ""; - } - - function collectVisibleText(node) { - if (!node) { - return ""; - } - - if (node.nodeType == Node.TEXT_NODE) { - return isNodeVisible(node) ? String(node.textContent || "") : ""; - } - - var walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { - acceptNode: function (textNode) { - return isNodeVisible(textNode) && String(textNode.textContent || "").trim() - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT; - } - }); - - var parts = []; - var current = walker.nextNode(); - while (current) { - parts.push(String(current.textContent || "").trim()); - current = walker.nextNode(); - } - - return parts.join(" ").replace(/\s+/g, " ").trim(); - } - - function extractTableAsTsv(table) { - return Array.from(table.rows || []).map(function (row) { - return Array.from(row.cells || []).filter(isNodeVisible).map(function (cell) { - return collectVisibleText(cell).replace(/\s*\n+\s*/g, " ").trim(); - }).join("\t"); - }).filter(Boolean).join("\n"); - } - - function extractCodeText(container) { - var clone = container.cloneNode(true); - Array.from(clone.querySelectorAll("[aria-hidden='true'], .line-numbers, .line-number, .lineno, .gutter, .blob-num")).forEach(function (element) { - element.remove(); - }); - return collectVisibleText(clone).replace(/\u00a0/g, " ").trim(); - } - - function extractListAsText(container) { - var isOrdered = container.nodeName.toUpperCase() === "OL"; - var items = Array.from(container.children).filter(function (child) { - return child.nodeName.toUpperCase() === "LI" && isNodeVisible(child); - }); - - return items.map(function (li, index) { - var nestedList = li.querySelector("ul, ol"); - var mainText = ""; - - if (nestedList) { - var clone = li.cloneNode(true); - Array.from(clone.querySelectorAll("ul, ol")).forEach(function (nested) { - nested.remove(); - }); - mainText = collectVisibleText(clone).trim(); - } else { - mainText = collectVisibleText(li).trim(); - } - - var prefix = isOrdered ? (index + 1) + ". " : "- "; - var line = prefix + mainText; - - if (nestedList) { - var nestedLines = extractListAsText(nestedList).split("\n").map(function (nestedLine) { - return " " + nestedLine; - }).join("\n"); - line += "\n" + nestedLines; - } - - return line; - }).join("\n"); - } - - function sanitizeText(text) { - return String(text || "") - .replace(/\u200B/g, "") // zero-width spaces - .replace(/\u200C/g, "") // zero-width non-joiner - .replace(/\u200D/g, "") // zero-width joiner - .replace(/\uFEFF/g, "") // BOM - .replace(/\u00A0/g, " ") // non-breaking space → regular space - .replace(/[ \t]+/g, " ") // collapse horizontal whitespace - .replace(/(\r?\n){3,}/g, "\n\n") // max 2 consecutive newlines - .trim(); - } - - function getPlainText(node) { - if (!node) { - return ""; - } - - switch (node.nodeName.toUpperCase()) { - case "INPUT": - case "TEXTAREA": - return node.value || ""; - case "SELECT": - return Array.from(node.selectedOptions).map(function (option) { - return option.innerText; - }).join("\n"); - case "IMG": - return getImageText(node); - default: - return collectVisibleText(node); - } - } - - async function copyCommand(clickedElement, source, options) { - var precisionTarget = resolvePrecisionTarget(clickedElement, options); - if (!precisionTarget) { - return; - } - await executePrecisionCopy(precisionTarget, source); - } - - async function executePrecisionCopy(precisionTarget, source) { - var text = getText(precisionTarget); - if (!text) { - return; - } - - // Extract HTML for rich-text copy support - var htmlContent = getHtmlContent(precisionTarget); - - var result = "copied"; - await copy(text, htmlContent); - - hoverState.scopeLevel = 0; - hoverState.scopeAnchorClientX = null; - hoverState.scopeAnchorClientY = null; - - showCopyFeedback(precisionTarget.rect, result); - var analyticsEvents = [{ - type: getAnalyticsTypeForResult(result, source || "click", precisionTarget.kind == "selection"), - hostname: window.location.hostname, - toastKind: getToastAnalyticsKind(result), - }]; - if ((source || "click") == "shortcut") { - analyticsEvents.push({ - type: "shortcut", - hostname: window.location.hostname, - }); - } - saveAnalyticsEvents(analyticsEvents); - await saveHistory(text, result, source || "click", precisionTarget.kind == "selection"); - } - - function getHtmlContent(target) { - if (!target) { - return ""; - } - - try { - if (target.range) { - var fragment = target.range.cloneContents(); - var wrapper = document.createElement("div"); - wrapper.appendChild(fragment); - return sanitizeHtml(serializeNodeChildrenToHtml(wrapper)); - } - - var node = target.node; - var element = getElementNode(node); - if (element && element.outerHTML) { - return sanitizeHtml(element.outerHTML); - } - } catch (error) { - // Silently fall back to no HTML - } - - return ""; - } - - function serializeNodeChildrenToHtml(node) { - return Array.from(node.childNodes).map(serializeNodeToHtml).join(""); - } - - function serializeNodeToHtml(node) { - if (node.nodeType == Node.TEXT_NODE) { - return escapeHtmlText(node.textContent || ""); - } - - if (node.nodeType == Node.ELEMENT_NODE && node.outerHTML) { - return node.outerHTML; - } - - return new XMLSerializer().serializeToString(node); - } - - function escapeHtmlText(text) { - return String(text) - .replace(/&/g, "&") - .replace(//g, ">"); - } - - function sanitizeHtml(html) { - if (!html) { - return ""; - } - - return html - .replace(//gi, "") - .replace(/\son\w+\s*=\s*["'][^"']*["']/gi, "") - .replace(/\son\w+\s*=\s*\S+/gi, "") - .replace(/javascript\s*:/gi, "") - .replace(/\sdata-track[\w-]*\s*=\s*["'][^"']*["']/gi, "") - .replace(/\sdata-analytics[\w-]*\s*=\s*["'][^"']*["']/gi, ""); - } - - async function copy(text, htmlContent) { - // Try rich-text copy with both text/plain and text/html via ClipboardItem API - if (htmlContent && navigator.clipboard && typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined") { - try { - var textBlob = new Blob([text], { type: "text/plain" }); - var htmlBlob = new Blob([htmlContent], { type: "text/html" }); - await navigator.clipboard.write([ - new ClipboardItem({ - "text/plain": textBlob, - "text/html": htmlBlob - }) - ]); - return; - } catch (error) { - console.warn("ClipboardItem write failed, falling back to writeText.", error); - } - } - - // Fallback: plain-text only via writeText - if (navigator.clipboard && typeof navigator.clipboard.writeText == "function") { - try { - await navigator.clipboard.writeText(text); - return; - } catch (error) { - console.warn("Clipboard writeText failed, using fallback copy.", error); - } - } - - // Final fallback: execCommand - var container = document.body || document.documentElement; - var textArea = document.createElement("textarea"); - textArea.style.cssText = "position:absolute;left:-100%;top:0;"; - - try { - container.appendChild(textArea); - textArea.value = text; - textArea.select(); - suppressNativeCopyTracking = true; - if (!document.execCommand("copy")) { - console.error("Copy failed."); - } - } finally { - suppressNativeCopyTracking = false; - textArea.remove(); - } - } - - function showCopyFeedback(rect, result) { - var state = getOverlayState(); - - if (hasRenderableRect(rect)) { - positionOverlayBox(state.feedback, rect, 4); - state.feedback.style.animationDuration = Math.max(350, settings.toastDurationMs) + "ms"; - restartAnimation(state.feedback, "visible"); - } - - spawnCursorToast(getCopyToastText(result), getToastPageX(rect), getToastPageY(rect)); - } - - function getCopyToastText(result) { - return t("toast_copied", "Copied!"); - } - - function getToastPageX(rect) { - if (hoverState.pointerPageX !== null) { - return hoverState.pointerPageX; - } - - return rect.left + window.scrollX + (rect.width / 2); - } - - function getToastPageY(rect) { - if (hoverState.pointerPageY !== null) { - return hoverState.pointerPageY; - } - - return rect.top + window.scrollY + Math.min(24, rect.height / 2); - } - - function spawnCursorToast(label, pageX, pageY) { - var state = getOverlayState(); - var toast = document.createElement("div"); - toast.className = "cursor-toast"; - toast.textContent = label; - - // Viewport-aware positioning: clamp toast within visible bounds - var toastW = 80; // estimated width - var toastH = 28; // estimated height - var margin = 8; - var clientX = pageX - window.scrollX; - var clientY = pageY - window.scrollY; - - // Horizontal: prefer right of cursor, flip left if overflow - var adjustedClientX = clientX + 16; - if (adjustedClientX + toastW + margin > window.innerWidth) { - adjustedClientX = Math.max(margin, clientX - toastW - 16); - } - - // Vertical: prefer above cursor, flip below if overflow - var adjustedClientY = clientY - 18; - if (adjustedClientY - toastH < margin) { - adjustedClientY = Math.min(window.innerHeight - toastH - margin, clientY + 24); - } - - // Convert back to page coordinates - var finalPageX = adjustedClientX + window.scrollX; - var finalPageY = adjustedClientY + window.scrollY; + } + return; + } + + const shortcutTarget = helpers.resolveShortcutTarget(); + if (!shortcutTarget) { + helpers.saveAnalyticsEvent({ + toastKind: "status", + hostname: window.location.hostname, + }); + helpers.showStatusToast(t("shortcut_unavailable", "No hovered or focused target to copy.")); + return; + } + + if (helpers.shouldIgnoreElement(shortcutTarget)) { + helpers.saveAnalyticsEvent({ + type: "editableSkipped", + hostname: window.location.hostname, + toastKind: "status", + }); + helpers.showStatusToast(t("unsupported_surface_status", "Editing surface skipped")); + return; + } + + helpers.copyCommand(shortcutTarget, "shortcut", { + preferSelection: true, + }).catch(function (error) { + if (handleExtensionContextError(error)) { + return; + } + console.error("Shortcut copy failed.", error); + }); + } - toast.style.left = Math.round(finalPageX) + "px"; - toast.style.top = Math.round(finalPageY) + "px"; - toast.style.animationDuration = settings.toastDurationMs + "ms"; - state.layer.appendChild(toast); - toast.addEventListener("animationend", function () { - toast.remove(); - }, { once: true }); + function attachExtensionListeners() { + if (state.extensionContextInvalidated) { + return; } - function showStatusToast(label) { - var state = getOverlayState(); - var toast = document.createElement("div"); - toast.className = "cursor-toast"; - toast.textContent = label; - toast.style.left = Math.round(window.scrollX + (window.innerWidth / 2)) + "px"; - toast.style.top = Math.round(window.scrollY + Math.min(window.innerHeight * 0.3, 180)) + "px"; - toast.style.transform = "translateX(-50%)"; - toast.style.animationDuration = settings.toastDurationMs + "ms"; - state.layer.appendChild(toast); - toast.addEventListener("animationend", function () { - toast.remove(); - }, { once: true }); + try { + utils.addListenerSafely(chrome.storage.onChanged, onStorageChanged); + } catch (error) { + handleExtensionContextError(error); } - async function saveHistory(text, result, source, isSelectionBased) { - if (!settings.copyHistoryLimit) { - return; - } - - try { - var current = await utils.safeStorageGet("local", { copyHistory: [] }); - var nextHistory = utils.pushHistoryEntry(current.copyHistory, { - text: text, - snippet: utils.getTextSnippet(text), - source: source, - mode: "copy", - url: window.location.href, - hostname: window.location.hostname, - pinned: false, - replayCount: 0, - lastReplayedAt: isSelectionBased ? Date.now() : 0, - }, settings.copyHistoryLimit); - - await utils.safeStorageSet("local", { copyHistory: nextHistory }); - } catch (error) { - if (handleExtensionContextError(error)) { - return; - } - - console.warn("Saving copy history failed.", error); - } + try { + utils.addListenerSafely(chrome.runtime.onMessage, onRuntimeMessage); + } catch (error) { + handleExtensionContextError(error); } + } - async function saveAnalyticsEvent(event) { - return saveAnalyticsEvents([event]); + function removeExtensionListeners() { + try { + utils.removeListenerSafely(chrome.storage.onChanged, onStorageChanged); + } catch (error) { + // Ignore invalidated contexts during teardown. } - async function saveAnalyticsEvents(events) { - try { - var current = await utils.safeStorageGet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); - var nextAnalytics = current.copyAnalytics; - (Array.isArray(events) ? events : [events]).forEach(function (event) { - nextAnalytics = utils.recordAnalyticsEvent(nextAnalytics, event || {}); - }); - await utils.safeStorageSet("local", { copyAnalytics: nextAnalytics }); - } catch (error) { - if (handleExtensionContextError(error)) { - return; - } - - console.warn("Saving copy analytics failed.", error); - } + try { + utils.removeListenerSafely(chrome.runtime.onMessage, onRuntimeMessage); + } catch (error) { + // Ignore invalidated contexts during teardown. } + } - function getAnalyticsTypeForResult(result, source, isSelectionBased) { - if (source == "history") { - return "historyReplayCopy"; - } - - if (isSelectionBased) { - return "selectionCopy"; - } + function attachDomListeners() { + document.addEventListener("click", events.handleClick, false); + document.addEventListener("mousemove", events.handleMouseMove, true); + document.addEventListener("mouseover", events.handleMouseOver, true); + document.addEventListener("mouseout", events.handleMouseOut, true); + document.addEventListener("copy", events.handleNativeCopy, true); + document.addEventListener("keydown", events.handleModifierChange, true); + document.addEventListener("keyup", events.handleModifierChange, true); + document.addEventListener("wheel", events.handleWheel, { capture: true, passive: false }); + document.addEventListener("scroll", events.handleViewportChange, true); + document.addEventListener("visibilitychange", events.handleVisibilityChange, true); + window.addEventListener("resize", events.handleViewportChange); + } - return "copy"; - } + function removeDomListeners() { + document.removeEventListener("click", events.handleClick, false); + document.removeEventListener("mousemove", events.handleMouseMove, true); + document.removeEventListener("mouseover", events.handleMouseOver, true); + document.removeEventListener("mouseout", events.handleMouseOut, true); + document.removeEventListener("copy", events.handleNativeCopy, true); + document.removeEventListener("keydown", events.handleModifierChange, true); + document.removeEventListener("keyup", events.handleModifierChange, true); + document.removeEventListener("wheel", events.handleWheel, true); + document.removeEventListener("scroll", events.handleViewportChange, true); + document.removeEventListener("visibilitychange", events.handleVisibilityChange, true); + window.removeEventListener("resize", events.handleViewportChange); + } - function getToastAnalyticsKind(result) { - return "copied"; + function handleExtensionContextError(error) { + if (!utils.isExtensionContextInvalidatedError(error)) { + return false; } - function resolveShortcutTarget() { - if (hoverState.hoveredElement && hoverState.hoveredElement.isConnected) { - return hoverState.hoveredElement; - } - - if (document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement) { - return document.activeElement; - } - - var selection = window.getSelection ? window.getSelection() : null; - if (selection && selection.anchorNode) { - return selection.anchorNode; - } + dispose(); + return true; + } - return null; + function dispose() { + if (state.disposed) { + return; } - function getNativeCopiedText() { - var activeElement = document.activeElement; - if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { - var start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; - var end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; - var value = String(activeElement.value || ""); - if (end > start) { - return value.slice(start, end).trim(); - } - } + state.disposed = true; + state.extensionContextInvalidated = true; - var selection = window.getSelection ? window.getSelection() : null; - return selection ? String(selection.toString() || "").trim() : ""; + if (state.hoverState.previewAnimationFrame) { + cancelAnimationFrame(state.hoverState.previewAnimationFrame); + state.hoverState.previewAnimationFrame = 0; } - function positionOverlayBox(element, rect, expansion) { - var pageTop = rect.top + window.scrollY - expansion; - var pageLeft = rect.left + window.scrollX - expansion; - var width = rect.width + (expansion * 2); - var height = rect.height + (expansion * 2); + removeExtensionListeners(); + removeDomListeners(); + helpers.hidePreview(); - element.style.top = Math.round(pageTop) + "px"; - element.style.left = Math.round(pageLeft) + "px"; - element.style.width = Math.max(1, Math.round(width)) + "px"; - element.style.height = Math.max(1, Math.round(height)) + "px"; + if (globalThis.__copyTextWithAltClickContentScriptState === state.scriptState) { + delete globalThis.__copyTextWithAltClickContentScriptState; } + } - function hasRenderableRect(rect) { - return !!rect && rect.width > 0 && rect.height > 0; + function updateSettings() { + if (!isExtensionUsable()) { + return; } - function restartAnimation(element, className) { - element.classList.remove(className); - void element.offsetWidth; - element.classList.add(className); - } - - function getOverlayState() { - if (overlayState && overlayState.host.isConnected) { - return overlayState; - } - - var host = document.createElement("div"); - host.setAttribute("data-copy-text-overlay-root", ""); - Object.assign(host.style, { - all: "initial", - position: "absolute", - top: "0", - left: "0", - width: "0", - height: "0", - zIndex: "2147483647", - pointerEvents: "none", - }); - - var shadowRoot = host.attachShadow({ mode: "closed" }); - var style = document.createElement("style"); - style.textContent = [ - ":host { all: initial; }", - ".layer {", - " position: relative;", - " pointer-events: none;", - "}", - ".preview,", - ".feedback,", - ".cursor-toast,", - ".scope-badge,", - ".warn-badge {", - " position: absolute;", - " pointer-events: none;", - " box-sizing: border-box;", - "}", - /* ── Highlighter-style preview ── */ - ".preview {", - " z-index: 99998;", - " opacity: 0;", - " border: 2px solid rgba(250, 204, 21, 0.7);", - " border-radius: 6px;", - " background: rgba(250, 204, 21, 0.18);", - " mix-blend-mode: multiply;", - " box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.08), inset 0 0 12px rgba(250, 204, 21, 0.12);", - " transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1), top 100ms ease, left 100ms ease, width 100ms ease, height 100ms ease;", - "}", - ".preview.visible {", - " opacity: 1;", - "}", - /* Dark mode: switch to additive blend for white text */ - "@media (prefers-color-scheme: dark) {", - " .preview {", - " border-color: rgba(56, 189, 248, 0.6);", - " background: rgba(56, 189, 248, 0.12);", - " mix-blend-mode: screen;", - " box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.1), inset 0 0 12px rgba(56, 189, 248, 0.08);", - " }", - "}", - /* ── Scope badge ── */ - ".scope-badge {", - " z-index: 100001;", - " display: none;", - " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", - " font-size: 10px;", - " font-weight: 700;", - " line-height: 1;", - " letter-spacing: 0.04em;", - " text-transform: uppercase;", - " color: #92400e;", - " background: rgba(253, 230, 138, 0.92);", - " border: 1px solid rgba(250, 204, 21, 0.4);", - " padding: 3px 7px;", - " border-radius: 6px;", - " white-space: nowrap;", - "}", - "@media (prefers-color-scheme: dark) {", - " .scope-badge {", - " color: #bae6fd;", - " background: rgba(7, 89, 133, 0.88);", - " border-color: rgba(56, 189, 248, 0.35);", - " }", - "}", - /* ── Large selection warning ── */ - ".warn-badge {", - " z-index: 100001;", - " display: none;", - " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", - " font-size: 10px;", - " font-weight: 700;", - " line-height: 1;", - " color: #b91c1c;", - " background: rgba(254, 226, 226, 0.94);", - " border: 1px solid rgba(248, 113, 113, 0.4);", - " padding: 3px 7px;", - " border-radius: 6px;", - " white-space: nowrap;", - "}", - "@media (prefers-color-scheme: dark) {", - " .warn-badge {", - " color: #fca5a5;", - " background: rgba(127, 29, 29, 0.88);", - " border-color: rgba(248, 113, 113, 0.35);", - " }", - "}", - /* ── Copy feedback pulse ── */ - ".feedback {", - " z-index: 99999;", - " opacity: 0;", - " border: 1px solid rgba(110, 231, 183, 0.96);", - " border-radius: 8px;", - " background: linear-gradient(135deg, rgba(45, 212, 191, 0.22), rgba(34, 197, 94, 0.18));", - " box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.14), 0 0 28px rgba(45, 212, 191, 0.35);", - "}", - ".feedback.visible {", - " animation: feedbackPulse 1150ms cubic-bezier(0.16, 1, 0.3, 1) forwards;", - "}", - "@keyframes feedbackPulse {", - " 0% { opacity: 0.96; transform: scale(0.985); }", - " 58% { opacity: 0.7; transform: scale(1); }", - " 100% { opacity: 0; transform: scale(1.02); }", - "}", - /* ── Cursor toast ── */ - ".cursor-toast {", - " z-index: 100000;", - " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", - " font-size: 12px;", - " font-weight: 650;", - " line-height: 1;", - " letter-spacing: 0.01em;", - " color: #ecfeff;", - " white-space: nowrap;", - " padding: 7px 10px;", - " border-radius: 999px;", - " border: 1px solid rgba(125, 211, 252, 0.32);", - " background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(8, 47, 73, 0.92));", - " box-shadow: 0 10px 24px rgba(2, 6, 23, 0.28), 0 0 18px rgba(45, 212, 191, 0.24);", - " animation: cursorToastFloat 1250ms cubic-bezier(0.22, 1, 0.36, 1) forwards;", - "}", - "@keyframes cursorToastFloat {", - " 0% { opacity: 0; transform: translateY(4px); }", - " 12% { opacity: 1; transform: translateY(0); }", - " 100% { opacity: 0; transform: translateY(-28px); }", - "}", - "@media (prefers-reduced-motion: reduce) {", - " .preview { transition: none; }", - " .feedback.visible { animation-duration: 350ms; }", - " .cursor-toast { animation-duration: 700ms; }", - "}" - ].join("\n"); - - var layer = document.createElement("div"); - layer.className = "layer"; - - var preview = document.createElement("div"); - preview.className = "preview"; - - var feedback = document.createElement("div"); - feedback.className = "feedback"; - - var scopeBadge = document.createElement("div"); - scopeBadge.className = "scope-badge"; - - var warnBadge = document.createElement("div"); - warnBadge.className = "warn-badge"; - - layer.appendChild(preview); - layer.appendChild(feedback); - layer.appendChild(scopeBadge); - layer.appendChild(warnBadge); - shadowRoot.appendChild(style); - shadowRoot.appendChild(layer); + utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS).then(function (items) { + if (!isExtensionUsable()) { + return; + } - (document.documentElement || document.body).appendChild(host); + state.settings = utils.mergeSettings(items); - overlayState = { - host: host, - layer: layer, - preview: preview, - feedback: feedback, - scopeBadge: scopeBadge, - warnBadge: warnBadge, - }; + if (isCurrentHostExcluded() || !state.hoverState.previewModifierActive) { + helpers.hidePreview(); + } else if (helpers.shouldShowPreview()) { + helpers.schedulePreviewUpdate(); + } + }).catch(function (error) { + if (handleExtensionContextError(error)) { + return; + } - return overlayState; - } + console.warn("Updating settings failed.", error); + }); + } })(); diff --git a/src/content/events.js b/src/content/events.js new file mode 100644 index 0000000..a70bdb2 --- /dev/null +++ b/src/content/events.js @@ -0,0 +1,225 @@ +function createContentEvents(context) { + const utils = context.utils; + const hoverState = context.state.hoverState; + const helpers = context.helpers; + + function handleClick(event) { + if (!context.isExtensionUsable()) { + return; + } + + const copyMode = utils.getCopyMode(context.state.settings.metaKey, event); + if (!copyMode || context.isCurrentHostExcluded()) { + if (copyMode && context.isCurrentHostExcluded()) { + helpers.saveAnalyticsEvent({ + type: "blockedExcluded", + hostname: window.location.hostname, + }); + } + return; + } + + if (helpers.shouldIgnoreElement(event.target)) { + helpers.saveAnalyticsEvent({ + type: "editableSkipped", + hostname: window.location.hostname, + }); + return; + } + + const targetToCopy = hoverState.lastRenderedTarget; + helpers.syncPointerState(event); + + if (targetToCopy) { + helpers.executePrecisionCopy(targetToCopy, "click").catch(function (error) { + if (context.handleExtensionContextError(error)) { + return; + } + console.error("Copy failed.", error); + }); + } else { + const composedTarget = event.composedPath ? event.composedPath()[0] : event.target; + const deepTarget = helpers.getElementNode(helpers.pierceShadowDOM(composedTarget, event.clientX, event.clientY)); + helpers.copyCommand(deepTarget, "click", { + clientX: event.clientX, + clientY: event.clientY, + preferSelection: true, + }).catch(function (error) { + if (context.handleExtensionContextError(error)) { + return; + } + console.error("Copy failed.", error); + }); + } + + event.preventDefault(); + } + + function handleMouseMove(event) { + if (!context.isExtensionUsable()) { + return; + } + + if (!utils.isPrimaryModifierPressed(context.state.settings.metaKey, event)) { + hoverState.pointerClientX = event.clientX; + hoverState.pointerClientY = event.clientY; + hoverState.pointerPageX = event.pageX; + hoverState.pointerPageY = event.pageY; + hoverState.previewModifierActive = false; + return; + } + + helpers.syncPointerState(event); + const composedTarget = event.composedPath ? event.composedPath()[0] : event.target; + hoverState.hoveredElement = helpers.getElementNode(helpers.pierceShadowDOM(composedTarget, event.clientX, event.clientY)); + hoverState.previewModifierActive = true; + hoverState.scopeLevel = 0; + + if (helpers.shouldShowPreview()) { + helpers.schedulePreviewUpdate(); + } else { + helpers.hidePreview(); + } + } + + function handleMouseOver(event) { + if (!context.isExtensionUsable()) { + return; + } + + helpers.syncPointerState(event); + hoverState.hoveredElement = helpers.getElementNode(event.target); + hoverState.previewModifierActive = utils.isPrimaryModifierPressed(context.state.settings.metaKey, event); + + if (helpers.shouldShowPreview()) { + helpers.schedulePreviewUpdate(); + } + } + + function handleMouseOut(event) { + if (!context.isExtensionUsable()) { + return; + } + + if (!event.relatedTarget) { + hoverState.hoveredElement = null; + helpers.hidePreview(); + return; + } + + hoverState.hoveredElement = helpers.getElementNode(event.relatedTarget); + + if (helpers.shouldShowPreview()) { + helpers.schedulePreviewUpdate(); + } else if (!hoverState.previewModifierActive) { + helpers.hidePreview(); + } + } + + function handleNativeCopy() { + if (!context.isExtensionUsable()) { + return; + } + + if (context.state.suppressNativeCopyTracking || context.isCurrentHostExcluded()) { + return; + } + + const copiedText = helpers.getNativeCopiedText(); + if (!copiedText) { + return; + } + + helpers.saveAnalyticsEvent({ + type: "nativeCopy", + hostname: window.location.hostname, + }); + helpers.saveHistory(copiedText, "copied", "native"); + } + + function handleModifierChange(event) { + if (!context.isExtensionUsable()) { + return; + } + + if (!utils.isModifierKeyEvent(event)) { + return; + } + + hoverState.previewModifierActive = utils.isPrimaryModifierPressed(context.state.settings.metaKey, event); + + if (!hoverState.previewModifierActive) { + hoverState.scopeLevel = 0; + } + + if (!hoverState.hoveredElement && hoverState.pointerClientX !== null && hoverState.pointerClientY !== null) { + const pointElement = document.elementFromPoint(hoverState.pointerClientX, hoverState.pointerClientY); + hoverState.hoveredElement = helpers.getElementNode(helpers.pierceShadowDOM(pointElement, hoverState.pointerClientX, hoverState.pointerClientY)); + } + + if (helpers.shouldShowPreview()) { + helpers.schedulePreviewUpdate(); + } else { + helpers.hidePreview(); + } + } + + function handleViewportChange() { + if (!context.isExtensionUsable()) { + return; + } + + if (helpers.shouldShowPreview()) { + helpers.schedulePreviewUpdate(); + } + } + + function handleVisibilityChange() { + if (!context.isExtensionUsable()) { + return; + } + + if (document.hidden) { + helpers.hidePreview(); + } + } + + function handleWheel(event) { + if (!context.isExtensionUsable()) { + return; + } + + if (!hoverState.previewModifierActive || !helpers.shouldShowPreview()) { + return; + } + + event.preventDefault(); + + const delta = event.deltaY < 0 ? 1 : -1; + const nextLevel = Math.max(0, Math.min(3, hoverState.scopeLevel + delta)); + if (nextLevel === hoverState.scopeLevel) { + return; + } + + hoverState.scopeLevel = nextLevel; + hoverState.scopeAnchorClientX = hoverState.pointerClientX; + hoverState.scopeAnchorClientY = hoverState.pointerClientY; + helpers.schedulePreviewUpdate(); + } + + return { + handleClick, + handleMouseMove, + handleMouseOver, + handleMouseOut, + handleNativeCopy, + handleModifierChange, + handleViewportChange, + handleVisibilityChange, + handleWheel, + }; +} + +module.exports = { + createContentEvents, +}; diff --git a/src/content/helpers.js b/src/content/helpers.js new file mode 100644 index 0000000..7af9f83 --- /dev/null +++ b/src/content/helpers.js @@ -0,0 +1,1292 @@ +function createContentHelpers(context) { + const utils = context.utils; + const hoverState = context.state.hoverState; + + const SCOPE_WORD = 0; + const SCOPE_SENTENCE = 1; + const SCOPE_PARAGRAPH = 2; + const SCOPE_CONTAINER = 3; + + function settings() { + return context.state.settings; + } + + function pierceShadowDOM(element, x, y) { + if (!element) { + return element; + } + const root = element.shadowRoot; + if (!root) { + return element; + } + const deeper = root.elementFromPoint(x, y); + if (!deeper || deeper === element) { + return element; + } + return pierceShadowDOM(deeper, x, y); + } + + function syncPointerState(event) { + hoverState.pointerPageX = event.pageX; + hoverState.pointerPageY = event.pageY; + hoverState.pointerClientX = event.clientX; + hoverState.pointerClientY = event.clientY; + } + + function resolveScopedTarget(sourceNode, clientX, clientY, level) { + if (level <= SCOPE_WORD) { + return null; + } + + const caretRange = getCaretRangeAtPoint(clientX, clientY); + if (!caretRange) { + return null; + } + + const textNode = caretRange.startContainer; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + return null; + } + + const fullText = textNode.textContent || ""; + + if (level === SCOPE_SENTENCE) { + const sentenceRange = expandToSentence(textNode, caretRange.startOffset); + if (sentenceRange) { + return { + kind: "scope", + node: textNode, + range: sentenceRange, + rect: getRangeBoundingRect(sentenceRange), + text: sentenceRange.toString().trim(), + }; + } + } + + if (level === SCOPE_PARAGRAPH) { + let paragraphElement = getElementNode(textNode); + while (paragraphElement && paragraphElement !== document.body) { + const display = window.getComputedStyle ? window.getComputedStyle(paragraphElement).display : ""; + if (display === "block" || display === "list-item" || display === "flex" || paragraphElement.nodeName === "P" || paragraphElement.nodeName === "LI") { + break; + } + paragraphElement = paragraphElement.parentElement; + } + if (paragraphElement && paragraphElement !== document.body) { + return createElementTarget(paragraphElement); + } + } + + if (level === SCOPE_CONTAINER) { + const container = getElementNode(textNode); + if (container && container.parentElement && container.parentElement !== document.body && container.parentElement !== document.documentElement) { + return createElementTarget(container.parentElement); + } + } + + return null; + } + + function expandToSentence(textNode, offset) { + const text = textNode.textContent || ""; + if (!text.trim()) { + return null; + } + + const sentenceBreaks = /[.!?。!?]+[\s]*/g; + const sentences = []; + let lastEnd = 0; + let match; + while ((match = sentenceBreaks.exec(text)) !== null) { + sentences.push({ start: lastEnd, end: match.index + match[0].length }); + lastEnd = match.index + match[0].length; + } + if (lastEnd < text.length) { + sentences.push({ start: lastEnd, end: text.length }); + } + + if (sentences.length === 0) { + sentences.push({ start: 0, end: text.length }); + } + + let target = sentences[0]; + for (let index = 0; index < sentences.length; index += 1) { + if (offset >= sentences[index].start && offset <= sentences[index].end) { + target = sentences[index]; + break; + } + } + + const range = document.createRange(); + range.setStart(textNode, target.start); + range.setEnd(textNode, Math.min(target.end, text.length)); + return range; + } + + function resolvePrecisionTarget(sourceNode, options) { + const localContext = options || {}; + const sourceElement = getElementNode(sourceNode); + const clientX = Number.isFinite(localContext.clientX) ? localContext.clientX : hoverState.pointerClientX; + const clientY = Number.isFinite(localContext.clientY) ? localContext.clientY : hoverState.pointerClientY; + + if (localContext.preferSelection !== false) { + const selectionTarget = getSelectionTarget(sourceElement, clientX, clientY); + if (selectionTarget) { + return selectionTarget; + } + } + + const scopeClientX = hoverState.scopeAnchorClientX !== null ? hoverState.scopeAnchorClientX : clientX; + const scopeClientY = hoverState.scopeAnchorClientY !== null ? hoverState.scopeAnchorClientY : clientY; + if (hoverState.scopeLevel > SCOPE_WORD) { + const scopedTarget = resolveScopedTarget(sourceElement, scopeClientX, scopeClientY, hoverState.scopeLevel); + if (scopedTarget) { + return scopedTarget; + } + } + + const deepTextTarget = getDeepTextTarget(clientX, clientY); + const fallbackElement = getDeepElementTarget(sourceElement, clientX, clientY); + const preliminaryTarget = deepTextTarget || createElementTarget(fallbackElement); + if (!preliminaryTarget) { + return null; + } + + const extractionContext = resolveExtractionContext(preliminaryTarget); + if (!extractionContext) { + return preliminaryTarget; + } + + switch (extractionContext.kind) { + case "selection": + case "scope": + return extractionContext; + case "table": + return { kind: "table", table: extractionContext.table, rect: extractionContext.table.getBoundingClientRect(), node: extractionContext.table }; + case "code": + return { kind: "code", container: extractionContext.container, rect: extractionContext.container.getBoundingClientRect(), node: extractionContext.container }; + case "list": + return { kind: "list", container: extractionContext.container, rect: extractionContext.container.getBoundingClientRect(), node: extractionContext.container }; + case "link": + return { kind: "link", anchor: extractionContext.anchor, rect: extractionContext.anchor.getBoundingClientRect(), node: extractionContext.anchor }; + case "image": + return { kind: "image", element: extractionContext.element, rect: extractionContext.element.getBoundingClientRect(), node: extractionContext.element }; + case "control": + return { kind: "control", element: extractionContext.element, rect: extractionContext.element.getBoundingClientRect(), node: extractionContext.element }; + case "text": { + let rect = null; + if (extractionContext.node && extractionContext.node.nodeType === Node.TEXT_NODE) { + const textRange = document.createRange(); + textRange.selectNodeContents(extractionContext.node); + rect = getRangeBoundingRect(textRange); + } else if (extractionContext.node && extractionContext.node.getBoundingClientRect) { + rect = extractionContext.node.getBoundingClientRect(); + } + return { kind: "text", node: extractionContext.node, rect: rect || preliminaryTarget.rect }; + } + default: + return preliminaryTarget; + } + } + + function getSelectionTarget(sourceElement, clientX, clientY) { + const selection = window.getSelection ? window.getSelection() : null; + if (selection && selection.rangeCount && !selection.isCollapsed) { + const selectionText = String(selection.toString() || "").trim(); + if (selectionText) { + const range = selection.getRangeAt(0).cloneRange(); + const rect = getRangeBoundingRect(range); + const commonNode = selection.anchorNode || range.commonAncestorContainer; + if ((!Number.isFinite(clientX) || !Number.isFinite(clientY)) || isPointInsideRect(rect, clientX, clientY) || (sourceElement && commonNode && sourceElement.contains(getElementNode(commonNode)))) { + return { + kind: "selection", + range: range, + rect: rect, + text: selectionText, + node: commonNode, + }; + } + } + } + + const activeElement = document.activeElement; + if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { + const start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; + const end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; + if (end > start) { + const selectedText = String(activeElement.value || "").slice(start, end).trim(); + if (selectedText && (!sourceElement || sourceElement === activeElement || activeElement.contains(sourceElement))) { + return { + kind: "selection", + rect: activeElement.getBoundingClientRect(), + text: selectedText, + node: activeElement, + }; + } + } + } + + return null; + } + + function getDeepTextTarget(clientX, clientY) { + if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return null; + } + + const range = getCaretRangeAtPoint(clientX, clientY); + if (!range) { + return null; + } + + const node = range.startContainer; + if (node && node.nodeType == Node.TEXT_NODE && String(node.textContent || "").trim() && isNodeVisible(node)) { + const textRange = document.createRange(); + textRange.selectNodeContents(node); + return { + kind: "text", + node: node, + rect: getRangeBoundingRect(textRange), + }; + } + + return null; + } + + function getDeepElementTarget(sourceElement, clientX, clientY) { + const element = Number.isFinite(clientX) && Number.isFinite(clientY) + ? pierceShadowDOM(document.elementFromPoint(clientX, clientY), clientX, clientY) + : sourceElement; + return getClosestMeaningfulElement(element || sourceElement); + } + + function getCaretRangeAtPoint(clientX, clientY) { + if (document.caretPositionFromPoint) { + const position = document.caretPositionFromPoint(clientX, clientY); + if (position && position.offsetNode) { + const range = document.createRange(); + range.setStart(position.offsetNode, position.offset); + range.setEnd(position.offsetNode, position.offset); + return range; + } + } + + if (document.caretRangeFromPoint) { + return document.caretRangeFromPoint(clientX, clientY); + } + + return null; + } + + function getClosestMeaningfulElement(element) { + let current = getElementNode(element); + while (current) { + if (hasMeaningfulText(current) || current.nodeName.toUpperCase() == "IMG" || current.nodeName.toUpperCase() == "INPUT" || current.nodeName.toUpperCase() == "TEXTAREA" || current.nodeName.toUpperCase() == "SELECT") { + return current; + } + current = current.parentElement; + } + return getElementNode(element); + } + + function createElementTarget(element) { + if (!element) { + return null; + } + + return { + kind: "element", + node: element, + rect: element.getBoundingClientRect ? element.getBoundingClientRect() : null, + }; + } + + function hasMeaningfulText(node) { + if (!node) { + return false; + } + if (node.nodeType == Node.TEXT_NODE) { + return String(node.textContent || "").trim().length > 0; + } + if (node.nodeType != Node.ELEMENT_NODE) { + return false; + } + if (node.nodeName.toUpperCase() == "IMG") { + return !!getImageText(node); + } + return collectVisibleText(node).length > 0; + } + + function isNodeVisible(node) { + const element = getElementNode(node); + if (!element) { + return false; + } + if (element.hidden || element.getAttribute("aria-hidden") == "true") { + return false; + } + const style = window.getComputedStyle ? window.getComputedStyle(element) : null; + if (style && (style.display == "none" || style.visibility == "hidden")) { + return false; + } + return true; + } + + function getRangeBoundingRect(range) { + if (!range) { + return null; + } + + const rects = Array.from(range.getClientRects ? range.getClientRects() : []).filter(function (rect) { + return rect.width > 0 && rect.height > 0; + }); + if (!rects.length) { + const fallback = range.getBoundingClientRect ? range.getBoundingClientRect() : null; + return fallback && fallback.width > 0 && fallback.height > 0 ? fallback : null; + } + + return rects.reduce(function (acc, current) { + if (!acc) { + return { + left: current.left, + top: current.top, + right: current.right, + bottom: current.bottom, + width: current.width, + height: current.height, + }; + } + + const left = Math.min(acc.left, current.left); + const top = Math.min(acc.top, current.top); + const right = Math.max(acc.right, current.right); + const bottom = Math.max(acc.bottom, current.bottom); + return { + left: left, + top: top, + right: right, + bottom: bottom, + width: right - left, + height: bottom - top, + }; + }, null); + } + + function isPointInsideRect(rect, clientX, clientY) { + if (!rect || !Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return false; + } + return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom; + } + + function shouldIgnoreElement(node) { + return settings().avoidEditable && utils.isEditableSurface(getElementNode(node)); + } + + function shouldShowPreview() { + return settings().previewEnabled + && hoverState.previewModifierActive + && !context.isCurrentHostExcluded() + && !!hoverState.hoveredElement + && hoverState.hoveredElement.isConnected + && !shouldIgnoreElement(hoverState.hoveredElement); + } + + function schedulePreviewUpdate() { + if (!shouldShowPreview()) { + hidePreview(); + return; + } + + if (hoverState.previewAnimationFrame) { + return; + } + + hoverState.previewAnimationFrame = window.requestAnimationFrame(function () { + hoverState.previewAnimationFrame = 0; + renderPreview(); + }); + } + + function renderPreview() { + if (!shouldShowPreview()) { + hidePreview(); + return; + } + + const precisionTarget = resolvePrecisionTarget(hoverState.hoveredElement, { + clientX: hoverState.pointerClientX, + clientY: hoverState.pointerClientY, + preferSelection: true, + }); + if (!precisionTarget || !precisionTarget.rect) { + hidePreview(); + return; + } + + const rect = precisionTarget.rect; + if (!hasRenderableRect(rect)) { + hidePreview(); + return; + } + + hoverState.lastRenderedTarget = precisionTarget; + + const overlayState = getOverlayState(); + positionOverlayBox(overlayState.preview, rect, 2); + overlayState.preview.classList.add("visible"); + + const scopeLabels = ["", "Sentence", "Paragraph", "Container"]; + if (hoverState.scopeLevel > 0 && hoverState.scopeLevel < scopeLabels.length) { + overlayState.scopeBadge.textContent = scopeLabels[hoverState.scopeLevel]; + overlayState.scopeBadge.style.display = "block"; + const previewTop = parseFloat(overlayState.preview.style.top) || 0; + const previewLeft = parseFloat(overlayState.preview.style.left) || 0; + overlayState.scopeBadge.style.top = (previewTop - 22) + "px"; + overlayState.scopeBadge.style.left = previewLeft + "px"; + } else { + overlayState.scopeBadge.style.display = "none"; + } + + const textLength = getText(precisionTarget).length; + if (textLength > 3000) { + overlayState.warnBadge.textContent = "⚠ " + Math.round(textLength / 1000) + "k chars"; + overlayState.warnBadge.style.display = "block"; + const previewTop = parseFloat(overlayState.preview.style.top) || 0; + const previewLeft = parseFloat(overlayState.preview.style.left) || 0; + const previewWidth = parseFloat(overlayState.preview.style.width) || 0; + overlayState.warnBadge.style.top = (previewTop - 22) + "px"; + overlayState.warnBadge.style.left = (previewLeft + previewWidth - 80) + "px"; + } else { + overlayState.warnBadge.style.display = "none"; + } + } + + function hidePreview() { + if (hoverState.previewAnimationFrame) { + window.cancelAnimationFrame(hoverState.previewAnimationFrame); + hoverState.previewAnimationFrame = 0; + } + + if (!context.state.overlayState) { + hoverState.lastRenderedTarget = null; + return; + } + + hoverState.lastRenderedTarget = null; + context.state.overlayState.preview.classList.remove("visible"); + context.state.overlayState.scopeBadge.style.display = "none"; + context.state.overlayState.warnBadge.style.display = "none"; + } + + function getText(target) { + const extractionContext = resolveExtractionContext(target); + if (!extractionContext) { + return ""; + } + + let raw; + switch (extractionContext.kind) { + case "selection": + raw = extractionContext.text; + break; + case "scope": + raw = extractionContext.text || collectVisibleText(extractionContext.node).trim(); + break; + case "table": + raw = extractTableAsTsv(extractionContext.table); + break; + case "code": + raw = extractCodeText(extractionContext.container); + break; + case "list": + raw = extractListAsText(extractionContext.container); + break; + case "image": + raw = getImageText(extractionContext.element); + break; + case "link": + raw = "[" + (collectVisibleText(extractionContext.anchor).trim() || extractionContext.anchor.href) + "](" + extractionContext.anchor.href + ")"; + break; + case "control": + raw = getPlainText(extractionContext.element).trim(); + break; + default: + raw = collectVisibleText(extractionContext.node).trim(); + } + + return sanitizeText(raw); + } + + function resolveExtractionContext(target) { + if (!target) { + return null; + } + + if (target.kind == "selection" || target.kind == "scope") { + return target; + } + + const node = target.node || target.element || getElementNode(target); + const element = getElementNode(node); + if (!element) { + return null; + } + + if (element.nodeName.toUpperCase() == "IMG") { + return { kind: "image", element: element }; + } + + const table = typeof element.closest == "function" ? element.closest("table") : null; + if (table) { + return { kind: "table", table: table }; + } + + const codeContainer = typeof element.closest == "function" ? element.closest("pre, code") : null; + if (codeContainer) { + return { kind: "code", container: codeContainer }; + } + + const listContainer = typeof element.closest == "function" ? element.closest("ul, ol") : null; + if (listContainer) { + return { kind: "list", container: listContainer }; + } + + const anchor = typeof element.closest == "function" ? element.closest("a[href]") : null; + if (anchor) { + return { kind: "link", anchor: anchor }; + } + + const tagName = element.nodeName.toUpperCase(); + if (tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "SELECT") { + return { kind: "control", element: element }; + } + + return { kind: "text", node: node }; + } + + function getExtractionNode(node) { + const element = getElementNode(node); + if (!element) { + return null; + } + return element; + } + + function getElementNode(node) { + if (!node) { + return null; + } + + if (node.nodeType == Node.TEXT_NODE) { + return node.parentNode; + } + + return node.nodeType == Node.ELEMENT_NODE ? node : null; + } + + function getImageText(node) { + return node.getAttribute("src") || node.getAttribute("alt") || ""; + } + + function collectVisibleText(node) { + if (!node) { + return ""; + } + + if (node.nodeType == Node.TEXT_NODE) { + return isNodeVisible(node) ? String(node.textContent || "") : ""; + } + + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { + acceptNode: function (textNode) { + return isNodeVisible(textNode) && String(textNode.textContent || "").trim() + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + } + }); + + const parts = []; + let current = walker.nextNode(); + while (current) { + parts.push(String(current.textContent || "").trim()); + current = walker.nextNode(); + } + + return parts.join(" ").replace(/\s+/g, " ").trim(); + } + + function extractTableAsTsv(table) { + return Array.from(table.rows || []).map(function (row) { + return Array.from(row.cells || []).filter(isNodeVisible).map(function (cell) { + return collectVisibleText(cell).replace(/\s*\n+\s*/g, " ").trim(); + }).join("\t"); + }).filter(Boolean).join("\n"); + } + + function extractCodeText(container) { + const clone = container.cloneNode(true); + Array.from(clone.querySelectorAll("[aria-hidden='true'], .line-numbers, .line-number, .lineno, .gutter, .blob-num")).forEach(function (element) { + element.remove(); + }); + return collectVisibleText(clone).replace(/\u00a0/g, " ").trim(); + } + + function extractListAsText(container) { + const isOrdered = container.nodeName.toUpperCase() === "OL"; + const items = Array.from(container.children).filter(function (child) { + return child.nodeName.toUpperCase() === "LI" && isNodeVisible(child); + }); + + return items.map(function (li, index) { + const nestedList = li.querySelector("ul, ol"); + let mainText = ""; + + if (nestedList) { + const clone = li.cloneNode(true); + Array.from(clone.querySelectorAll("ul, ol")).forEach(function (nested) { + nested.remove(); + }); + mainText = collectVisibleText(clone).trim(); + } else { + mainText = collectVisibleText(li).trim(); + } + + const prefix = isOrdered ? (index + 1) + ". " : "- "; + let line = prefix + mainText; + + if (nestedList) { + const nestedLines = extractListAsText(nestedList).split("\n").map(function (nestedLine) { + return " " + nestedLine; + }).join("\n"); + line += "\n" + nestedLines; + } + + return line; + }).join("\n"); + } + + function sanitizeText(text) { + return String(text || "") + .replace(/\u200B/g, "") + .replace(/\u200C/g, "") + .replace(/\u200D/g, "") + .replace(/\uFEFF/g, "") + .replace(/\u00A0/g, " ") + .replace(/[ \t]+/g, " ") + .replace(/(\r?\n){3,}/g, "\n\n") + .trim(); + } + + function getPlainText(node) { + if (!node) { + return ""; + } + + switch (node.nodeName.toUpperCase()) { + case "INPUT": + case "TEXTAREA": + return node.value || ""; + case "SELECT": + return Array.from(node.selectedOptions).map(function (option) { + return option.innerText; + }).join("\n"); + case "IMG": + return getImageText(node); + default: + return collectVisibleText(node); + } + } + + async function copyCommand(clickedElement, source, options) { + const precisionTarget = resolvePrecisionTarget(clickedElement, options); + if (!precisionTarget) { + return; + } + await executePrecisionCopy(precisionTarget, source); + } + + async function executePrecisionCopy(precisionTarget, source) { + const text = getText(precisionTarget); + if (!text) { + return; + } + + const htmlContent = getHtmlContent(precisionTarget); + const result = "copied"; + await copy(text, htmlContent); + + hoverState.scopeLevel = 0; + hoverState.scopeAnchorClientX = null; + hoverState.scopeAnchorClientY = null; + + showCopyFeedback(precisionTarget.rect, result); + const analyticsEvents = [{ + type: getAnalyticsTypeForResult(result, source || "click", precisionTarget.kind == "selection"), + hostname: window.location.hostname, + toastKind: getToastAnalyticsKind(result), + }]; + if ((source || "click") == "shortcut") { + analyticsEvents.push({ + type: "shortcut", + hostname: window.location.hostname, + }); + } + saveAnalyticsEvents(analyticsEvents); + await saveHistory(text, result, source || "click", precisionTarget.kind == "selection"); + } + + function getHtmlContent(target) { + if (!target) { + return ""; + } + + try { + if (target.range) { + const fragment = target.range.cloneContents(); + const wrapper = document.createElement("div"); + wrapper.appendChild(fragment); + return sanitizeHtml(serializeNodeChildrenToHtml(wrapper)); + } + + const node = target.node; + const element = getElementNode(node); + if (element && element.outerHTML) { + return sanitizeHtml(element.outerHTML); + } + } catch (error) { + // Silently fall back to no HTML + } + + return ""; + } + + function serializeNodeChildrenToHtml(node) { + return Array.from(node.childNodes).map(serializeNodeToHtml).join(""); + } + + function serializeNodeToHtml(node) { + if (node.nodeType == Node.TEXT_NODE) { + return escapeHtmlText(node.textContent || ""); + } + + if (node.nodeType == Node.ELEMENT_NODE && node.outerHTML) { + return node.outerHTML; + } + + return new XMLSerializer().serializeToString(node); + } + + function escapeHtmlText(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">"); + } + + function sanitizeHtml(html) { + if (!html) { + return ""; + } + + return html + .replace(//gi, "") + .replace(/\son\w+\s*=\s*["'][^"']*["']/gi, "") + .replace(/\son\w+\s*=\s*\S+/gi, "") + .replace(/javascript\s*:/gi, "") + .replace(/\sdata-track[\w-]*\s*=\s*["'][^"']*["']/gi, "") + .replace(/\sdata-analytics[\w-]*\s*=\s*["'][^"']*["']/gi, ""); + } + + async function copy(text, htmlContent) { + if (htmlContent && navigator.clipboard && typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined") { + try { + const textBlob = new Blob([text], { type: "text/plain" }); + const htmlBlob = new Blob([htmlContent], { type: "text/html" }); + await navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": textBlob, + "text/html": htmlBlob + }) + ]); + return; + } catch (error) { + console.warn("ClipboardItem write failed, falling back to writeText.", error); + } + } + + if (navigator.clipboard && typeof navigator.clipboard.writeText == "function") { + try { + await navigator.clipboard.writeText(text); + return; + } catch (error) { + console.warn("Clipboard writeText failed, using fallback copy.", error); + } + } + + const container = document.body || document.documentElement; + const textArea = document.createElement("textarea"); + textArea.style.cssText = "position:absolute;left:-100%;top:0;"; + + try { + container.appendChild(textArea); + textArea.value = text; + textArea.select(); + context.state.suppressNativeCopyTracking = true; + if (!document.execCommand("copy")) { + console.error("Copy failed."); + } + } finally { + context.state.suppressNativeCopyTracking = false; + textArea.remove(); + } + } + + function showCopyFeedback(rect, result) { + const overlayState = getOverlayState(); + + if (hasRenderableRect(rect)) { + positionOverlayBox(overlayState.feedback, rect, 4); + overlayState.feedback.style.animationDuration = Math.max(350, settings().toastDurationMs) + "ms"; + restartAnimation(overlayState.feedback, "visible"); + } + + spawnCursorToast(getCopyToastText(result), getToastPageX(rect), getToastPageY(rect)); + } + + function getCopyToastText(result) { + return context.t("toast_copied", "Copied!"); + } + + function getToastPageX(rect) { + if (hoverState.pointerPageX !== null) { + return hoverState.pointerPageX; + } + + return rect.left + window.scrollX + (rect.width / 2); + } + + function getToastPageY(rect) { + if (hoverState.pointerPageY !== null) { + return hoverState.pointerPageY; + } + + return rect.top + window.scrollY + Math.min(24, rect.height / 2); + } + + function spawnCursorToast(label, pageX, pageY) { + const overlayState = getOverlayState(); + const toast = document.createElement("div"); + toast.className = "cursor-toast"; + toast.textContent = label; + + const toastW = 80; + const toastH = 28; + const margin = 8; + const clientX = pageX - window.scrollX; + const clientY = pageY - window.scrollY; + + let adjustedClientX = clientX + 16; + if (adjustedClientX + toastW + margin > window.innerWidth) { + adjustedClientX = Math.max(margin, clientX - toastW - 16); + } + + let adjustedClientY = clientY - 18; + if (adjustedClientY - toastH < margin) { + adjustedClientY = Math.min(window.innerHeight - toastH - margin, clientY + 24); + } + + const finalPageX = adjustedClientX + window.scrollX; + const finalPageY = adjustedClientY + window.scrollY; + + toast.style.left = Math.round(finalPageX) + "px"; + toast.style.top = Math.round(finalPageY) + "px"; + toast.style.animationDuration = settings().toastDurationMs + "ms"; + overlayState.layer.appendChild(toast); + toast.addEventListener("animationend", function () { + toast.remove(); + }, { once: true }); + } + + function showStatusToast(label) { + const overlayState = getOverlayState(); + const toast = document.createElement("div"); + toast.className = "cursor-toast"; + toast.textContent = label; + toast.style.left = Math.round(window.scrollX + (window.innerWidth / 2)) + "px"; + toast.style.top = Math.round(window.scrollY + Math.min(window.innerHeight * 0.3, 180)) + "px"; + toast.style.transform = "translateX(-50%)"; + toast.style.animationDuration = settings().toastDurationMs + "ms"; + overlayState.layer.appendChild(toast); + toast.addEventListener("animationend", function () { + toast.remove(); + }, { once: true }); + } + + async function saveHistory(text, result, source, isSelectionBased) { + if (!settings().copyHistoryLimit) { + return; + } + + try { + const current = await utils.safeStorageGet("local", { copyHistory: [] }); + const nextHistory = utils.pushHistoryEntry(current.copyHistory, { + text: text, + snippet: utils.getTextSnippet(text), + source: source, + mode: "copy", + url: window.location.href, + hostname: window.location.hostname, + pinned: false, + replayCount: 0, + lastReplayedAt: isSelectionBased ? Date.now() : 0, + }, settings().copyHistoryLimit); + + await utils.safeStorageSet("local", { copyHistory: nextHistory }); + } catch (error) { + if (context.handleExtensionContextError(error)) { + return; + } + + console.warn("Saving copy history failed.", error); + } + } + + async function saveAnalyticsEvent(event) { + return saveAnalyticsEvents([event]); + } + + async function saveAnalyticsEvents(events) { + try { + const current = await utils.safeStorageGet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); + let nextAnalytics = current.copyAnalytics; + (Array.isArray(events) ? events : [events]).forEach(function (event) { + nextAnalytics = utils.recordAnalyticsEvent(nextAnalytics, event || {}); + }); + await utils.safeStorageSet("local", { copyAnalytics: nextAnalytics }); + } catch (error) { + if (context.handleExtensionContextError(error)) { + return; + } + + console.warn("Saving copy analytics failed.", error); + } + } + + function getAnalyticsTypeForResult(result, source, isSelectionBased) { + if (source == "history") { + return "historyReplayCopy"; + } + + if (isSelectionBased) { + return "selectionCopy"; + } + + return "copy"; + } + + function getToastAnalyticsKind(result) { + return "copied"; + } + + function resolveShortcutTarget() { + if (hoverState.hoveredElement && hoverState.hoveredElement.isConnected) { + return hoverState.hoveredElement; + } + + if (document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement) { + return document.activeElement; + } + + const selection = window.getSelection ? window.getSelection() : null; + if (selection && selection.anchorNode) { + return selection.anchorNode; + } + + return null; + } + + function getNativeCopiedText() { + const activeElement = document.activeElement; + if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { + const start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; + const end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; + const value = String(activeElement.value || ""); + if (end > start) { + return value.slice(start, end).trim(); + } + } + + const selection = window.getSelection ? window.getSelection() : null; + return selection ? String(selection.toString() || "").trim() : ""; + } + + function positionOverlayBox(element, rect, expansion) { + const pageTop = rect.top + window.scrollY - expansion; + const pageLeft = rect.left + window.scrollX - expansion; + const width = rect.width + (expansion * 2); + const height = rect.height + (expansion * 2); + + element.style.top = Math.round(pageTop) + "px"; + element.style.left = Math.round(pageLeft) + "px"; + element.style.width = Math.max(1, Math.round(width)) + "px"; + element.style.height = Math.max(1, Math.round(height)) + "px"; + } + + function hasRenderableRect(rect) { + return !!rect && rect.width > 0 && rect.height > 0; + } + + function restartAnimation(element, className) { + element.classList.remove(className); + void element.offsetWidth; + element.classList.add(className); + } + + function getOverlayState() { + if (context.state.overlayState && context.state.overlayState.host.isConnected) { + return context.state.overlayState; + } + + const host = document.createElement("div"); + host.setAttribute("data-copy-text-overlay-root", ""); + Object.assign(host.style, { + all: "initial", + position: "absolute", + top: "0", + left: "0", + width: "0", + height: "0", + zIndex: "2147483647", + pointerEvents: "none", + }); + + const shadowRoot = host.attachShadow({ mode: "closed" }); + const style = document.createElement("style"); + style.textContent = [ + ":host { all: initial; }", + ".layer {", + " position: relative;", + " pointer-events: none;", + "}", + ".preview,", + ".feedback,", + ".cursor-toast,", + ".scope-badge,", + ".warn-badge {", + " position: absolute;", + " pointer-events: none;", + " box-sizing: border-box;", + "}", + ".preview {", + " z-index: 99998;", + " opacity: 0;", + " border: 2px solid rgba(250, 204, 21, 0.7);", + " border-radius: 6px;", + " background: rgba(250, 204, 21, 0.18);", + " mix-blend-mode: multiply;", + " box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.08), inset 0 0 12px rgba(250, 204, 21, 0.12);", + " transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1), top 100ms ease, left 100ms ease, width 100ms ease, height 100ms ease;", + "}", + ".preview.visible {", + " opacity: 1;", + "}", + "@media (prefers-color-scheme: dark) {", + " .preview {", + " border-color: rgba(56, 189, 248, 0.6);", + " background: rgba(56, 189, 248, 0.12);", + " mix-blend-mode: screen;", + " box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.1), inset 0 0 12px rgba(56, 189, 248, 0.08);", + " }", + "}", + ".scope-badge {", + " z-index: 100001;", + " display: none;", + " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", + " font-size: 10px;", + " font-weight: 700;", + " line-height: 1;", + " letter-spacing: 0.04em;", + " text-transform: uppercase;", + " color: #92400e;", + " background: rgba(253, 230, 138, 0.92);", + " border: 1px solid rgba(250, 204, 21, 0.4);", + " padding: 3px 7px;", + " border-radius: 6px;", + " white-space: nowrap;", + "}", + "@media (prefers-color-scheme: dark) {", + " .scope-badge {", + " color: #bae6fd;", + " background: rgba(7, 89, 133, 0.88);", + " border-color: rgba(56, 189, 248, 0.35);", + " }", + "}", + ".warn-badge {", + " z-index: 100001;", + " display: none;", + " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", + " font-size: 10px;", + " font-weight: 700;", + " line-height: 1;", + " color: #b91c1c;", + " background: rgba(254, 226, 226, 0.94);", + " border: 1px solid rgba(248, 113, 113, 0.4);", + " padding: 3px 7px;", + " border-radius: 6px;", + " white-space: nowrap;", + "}", + "@media (prefers-color-scheme: dark) {", + " .warn-badge {", + " color: #fca5a5;", + " background: rgba(127, 29, 29, 0.88);", + " border-color: rgba(248, 113, 113, 0.35);", + " }", + "}", + ".feedback {", + " z-index: 99999;", + " opacity: 0;", + " border: 1px solid rgba(110, 231, 183, 0.96);", + " border-radius: 8px;", + " background: linear-gradient(135deg, rgba(45, 212, 191, 0.22), rgba(34, 197, 94, 0.18));", + " box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.14), 0 0 28px rgba(45, 212, 191, 0.35);", + "}", + ".feedback.visible {", + " animation: feedbackPulse 1150ms cubic-bezier(0.16, 1, 0.3, 1) forwards;", + "}", + "@keyframes feedbackPulse {", + " 0% { opacity: 0.96; transform: scale(0.985); }", + " 58% { opacity: 0.7; transform: scale(1); }", + " 100% { opacity: 0; transform: scale(1.02); }", + "}", + ".cursor-toast {", + " z-index: 100000;", + " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", + " font-size: 12px;", + " font-weight: 650;", + " line-height: 1;", + " letter-spacing: 0.01em;", + " color: #ecfeff;", + " white-space: nowrap;", + " padding: 7px 10px;", + " border-radius: 999px;", + " border: 1px solid rgba(125, 211, 252, 0.32);", + " background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(8, 47, 73, 0.92));", + " box-shadow: 0 10px 24px rgba(2, 6, 23, 0.28), 0 0 18px rgba(45, 212, 191, 0.24);", + " animation: cursorToastFloat 1250ms cubic-bezier(0.22, 1, 0.36, 1) forwards;", + "}", + "@keyframes cursorToastFloat {", + " 0% { opacity: 0; transform: translateY(4px); }", + " 12% { opacity: 1; transform: translateY(0); }", + " 100% { opacity: 0; transform: translateY(-28px); }", + "}", + "@media (prefers-reduced-motion: reduce) {", + " .preview { transition: none; }", + " .feedback.visible { animation-duration: 350ms; }", + " .cursor-toast { animation-duration: 700ms; }", + "}", + ].join("\n"); + + const layer = document.createElement("div"); + layer.className = "layer"; + + const preview = document.createElement("div"); + preview.className = "preview"; + + const feedback = document.createElement("div"); + feedback.className = "feedback"; + + const scopeBadge = document.createElement("div"); + scopeBadge.className = "scope-badge"; + + const warnBadge = document.createElement("div"); + warnBadge.className = "warn-badge"; + + layer.appendChild(preview); + layer.appendChild(feedback); + layer.appendChild(scopeBadge); + layer.appendChild(warnBadge); + shadowRoot.appendChild(style); + shadowRoot.appendChild(layer); + + (document.documentElement || document.body).appendChild(host); + + context.state.overlayState = { + host: host, + layer: layer, + preview: preview, + feedback: feedback, + scopeBadge: scopeBadge, + warnBadge: warnBadge, + }; + + return context.state.overlayState; + } + + return { + SCOPE_WORD, + SCOPE_SENTENCE, + SCOPE_PARAGRAPH, + SCOPE_CONTAINER, + pierceShadowDOM, + syncPointerState, + resolveScopedTarget, + expandToSentence, + resolvePrecisionTarget, + getSelectionTarget, + getDeepTextTarget, + getDeepElementTarget, + getCaretRangeAtPoint, + getClosestMeaningfulElement, + createElementTarget, + hasMeaningfulText, + isNodeVisible, + getRangeBoundingRect, + isPointInsideRect, + shouldIgnoreElement, + shouldShowPreview, + schedulePreviewUpdate, + renderPreview, + hidePreview, + getText, + resolveExtractionContext, + getExtractionNode, + getElementNode, + getImageText, + collectVisibleText, + extractTableAsTsv, + extractCodeText, + extractListAsText, + sanitizeText, + getPlainText, + copyCommand, + executePrecisionCopy, + getHtmlContent, + serializeNodeChildrenToHtml, + serializeNodeToHtml, + escapeHtmlText, + sanitizeHtml, + copy, + showCopyFeedback, + getCopyToastText, + getToastPageX, + getToastPageY, + spawnCursorToast, + showStatusToast, + saveHistory, + saveAnalyticsEvent, + saveAnalyticsEvents, + getAnalyticsTypeForResult, + getToastAnalyticsKind, + resolveShortcutTarget, + getNativeCopiedText, + positionOverlayBox, + hasRenderableRect, + restartAnimation, + getOverlayState, + }; +} + +module.exports = { + createContentHelpers, +}; diff --git a/src/options/analytics-view.js b/src/options/analytics-view.js new file mode 100644 index 0000000..56efaad --- /dev/null +++ b/src/options/analytics-view.js @@ -0,0 +1,61 @@ +function createAnalyticsView(context) { + function renderAnalytics(analytics) { + document.getElementById("analytics_total_actions_value").textContent = String(analytics.totals.totalActions || 0); + document.getElementById("analytics_append_actions_value").textContent = String(analytics.totals.selectionCopies || 0); + document.getElementById("analytics_native_actions_value").textContent = String(analytics.totals.nativeCopies || 0); + document.getElementById("analytics_shortcut_actions_value").textContent = String(analytics.totals.shortcuts || 0); + document.getElementById("analytics_blocked_actions_value").textContent = String((analytics.totals.excludedBlocked || 0) + (analytics.totals.editableSkipped || 0)); + document.getElementById("analytics_toast_events_value").textContent = String( + (analytics.toastCounts.copied || 0) + (analytics.toastCounts.status || 0) + ); + + const list = document.getElementById("analytics_top_domains_list"); + list.textContent = ""; + + const topDomains = context.utils.getTopDomainStats(analytics, 5); + if (!topDomains.length) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = context.ui.t("analytics_empty_domains", "No domain activity yet."); + list.appendChild(empty); + return; + } + + topDomains.forEach(function (item) { + const card = document.createElement("div"); + card.className = "domain-item"; + + const row = document.createElement("div"); + row.className = "domain-row"; + + const name = document.createElement("div"); + name.className = "domain-name"; + name.textContent = item.hostname; + + const meta = document.createElement("div"); + meta.className = "history-meta"; + meta.appendChild(createHistoryChip(context.ui.t("analytics_total_actions", "Total actions") + ": " + item.totalActions, "domain-metric")); + meta.appendChild(createHistoryChip(context.ui.t("analytics_shortcut_actions", "Shortcut usage") + ": " + item.shortcuts, "domain-metric")); + + row.appendChild(name); + card.appendChild(row); + card.appendChild(meta); + list.appendChild(card); + }); + } + + function createHistoryChip(label, extraClass) { + const chip = document.createElement("span"); + chip.className = "history-chip" + (extraClass ? " " + extraClass : ""); + chip.textContent = label; + return chip; + } + + return { + renderAnalytics, + }; +} + +module.exports = { + createAnalyticsView, +}; diff --git a/src/options/domains.js b/src/options/domains.js new file mode 100644 index 0000000..0267a9b --- /dev/null +++ b/src/options/domains.js @@ -0,0 +1,78 @@ +function createDomainsController(context) { + async function addDomainFromInput() { + const input = document.getElementById("domain_input"); + const normalized = context.utils.normalizeDomain(input.value); + if (!normalized) { + return; + } + + context.state.settings.excludedDomains = context.utils.toggleDomain(context.state.settings.excludedDomains, normalized); + document.getElementById("excluded_domains_bulk").value = context.state.settings.excludedDomains.join("\n"); + input.value = ""; + + await context.utils.safeStorageSet("sync", context.state.settings); + await renderExcludedDomains(); + context.ui.showStatus(context.ui.t("save_status_saved", "Saved")); + } + + async function applyBulkDomains() { + context.state.settings.excludedDomains = context.utils.normalizeExcludedDomains(document.getElementById("excluded_domains_bulk").value); + document.getElementById("excluded_domains_bulk").value = context.state.settings.excludedDomains.join("\n"); + + await context.utils.safeStorageSet("sync", context.state.settings); + await renderExcludedDomains(); + context.ui.showStatus(context.ui.t("save_status_saved", "Saved")); + } + + async function renderExcludedDomains() { + const list = document.getElementById("excluded_domains_list"); + list.textContent = ""; + + if (!context.state.settings.excludedDomains.length) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = context.ui.t("excluded_domains_empty", "No excluded domains yet."); + list.appendChild(empty); + return; + } + + context.state.settings.excludedDomains.forEach(function (domain) { + const item = document.createElement("div"); + item.className = "domain-item"; + + const row = document.createElement("div"); + row.className = "domain-row"; + + const name = document.createElement("div"); + name.className = "domain-name"; + name.textContent = domain; + + const remove = document.createElement("button"); + remove.className = "secondary-button"; + remove.type = "button"; + remove.textContent = context.ui.t("domain_remove_button", "Remove"); + remove.addEventListener("click", async function () { + context.state.settings.excludedDomains = context.utils.toggleDomain(context.state.settings.excludedDomains, domain); + document.getElementById("excluded_domains_bulk").value = context.state.settings.excludedDomains.join("\n"); + await context.utils.safeStorageSet("sync", context.state.settings); + await renderExcludedDomains(); + context.ui.showStatus(context.ui.t("save_status_saved", "Saved")); + }); + + row.appendChild(name); + row.appendChild(remove); + item.appendChild(row); + list.appendChild(item); + }); + } + + return { + addDomainFromInput, + applyBulkDomains, + renderExcludedDomains, + }; +} + +module.exports = { + createDomainsController, +}; diff --git a/src/options/history-view.js b/src/options/history-view.js new file mode 100644 index 0000000..e5f1d2c --- /dev/null +++ b/src/options/history-view.js @@ -0,0 +1,402 @@ +function createOptionsHistoryView(context) { + function normalizeSourceClass(source) { + if (source == "click") return "extension"; + if (source == "shortcut") return "shortcut"; + if (source == "history") return "history"; + if (source == "native") return "native"; + return "unknown"; + } + + function createHistoryChip(label, extraClass) { + const chip = document.createElement("span"); + chip.className = "history-chip" + (extraClass ? " " + extraClass : ""); + chip.textContent = label; + return chip; + } + + function formatRelativeTime(value) { + const delta = Date.now() - Number(value || 0); + if (!Number.isFinite(delta) || delta < 60000) { + return context.ui.t("history_recency_now", "Just now"); + } + + const minutes = Math.round(delta / 60000); + if (minutes < 60) { + return minutes + "m ago"; + } + + const hours = Math.round(minutes / 60); + if (hours < 24) { + return hours + "h ago"; + } + + return Math.round(hours / 24) + "d ago"; + } + + function formatAbsoluteTime(value) { + const timestamp = Number(value || 0); + if (!Number.isFinite(timestamp) || !timestamp) { + return ""; + } + + try { + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); + } catch (error) { + return new Date(timestamp).toLocaleString(); + } + } + + function populateHistoryDomainFilter(history) { + const filter = document.getElementById("history_domain_filter"); + const currentValue = context.state.historyFilters.hostname; + const hosts = context.utils.getHistoryHostOptions(history); + + filter.textContent = ""; + + const allOption = document.createElement("option"); + allOption.value = "all"; + allOption.textContent = context.ui.t("history_filter_all", "All"); + filter.appendChild(allOption); + + hosts.forEach(function (hostname) { + const option = document.createElement("option"); + option.value = hostname; + option.textContent = hostname; + filter.appendChild(option); + }); + + filter.value = hosts.includes(currentValue) ? currentValue : "all"; + context.state.historyFilters.hostname = filter.value; + } + + async function renderHistoryAndAnalytics() { + const current = await context.utils.safeStorageGet("local", { + copyHistory: [], + copyAnalytics: context.utils.DEFAULT_ANALYTICS, + }); + + const history = Array.isArray(current.copyHistory) ? current.copyHistory : []; + const analytics = context.utils.normalizeAnalytics(current.copyAnalytics); + + populateHistoryDomainFilter(history); + context.analytics.renderAnalytics(analytics); + renderHistory(history); + } + + function renderHistory(history) { + const filteredHistory = context.utils.filterHistoryEntries(history, context.state.historyFilters); + const sortedHistory = context.utils.sortHistoryEntries(filteredHistory, context.state.historyFilters.sort); + const groupedHistory = context.utils.groupHistoryEntries(sortedHistory, context.state.historyFilters.group); + const list = document.getElementById("options_history_list"); + list.textContent = ""; + + if (!sortedHistory.length) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = history.length + ? context.ui.t("history_no_results", "No matching history entries.") + : context.ui.t("copy_history_empty", "No copied items yet."); + list.appendChild(empty); + updateBulkToolbar(); + return; + } + + groupedHistory.forEach(function (group) { + if (context.state.historyFilters.group != "none") { + const groupWrap = document.createElement("div"); + groupWrap.className = "history-group"; + + const groupLabel = document.createElement("div"); + groupLabel.className = "history-group-label"; + groupLabel.textContent = getGroupLabel(group); + groupWrap.appendChild(groupLabel); + + group.entries.forEach(function (item) { + groupWrap.appendChild(createHistoryItem(item)); + }); + list.appendChild(groupWrap); + return; + } + + group.entries.forEach(function (item) { + list.appendChild(createHistoryItem(item)); + }); + }); + + updateBulkToolbar(); + } + + function createHistoryItem(item) { + const wrapper = document.createElement("div"); + wrapper.className = "history-item"; + wrapper.classList.toggle("selected", context.state.selectedHistoryIds.has(item.id)); + + const top = document.createElement("div"); + top.className = "history-top"; + + const content = document.createElement("div"); + content.className = "field-group"; + + if (context.state.bulkSelectionMode) { + const selector = document.createElement("input"); + selector.type = "checkbox"; + selector.checked = context.state.selectedHistoryIds.has(item.id); + selector.addEventListener("change", function () { + if (selector.checked) { + context.state.selectedHistoryIds.add(item.id); + } else { + context.state.selectedHistoryIds.delete(item.id); + } + updateBulkToolbar(); + wrapper.classList.toggle("selected", context.state.selectedHistoryIds.has(item.id)); + }); + content.appendChild(selector); + } + + const snippet = document.createElement("div"); + snippet.className = "history-snippet"; + snippet.textContent = item.snippet || item.text; + + const meta = createHistoryMeta(item); + + content.appendChild(snippet); + content.appendChild(meta); + + const actions = document.createElement("div"); + actions.className = "history-actions"; + + const copyButton = document.createElement("button"); + copyButton.className = "secondary-button"; + copyButton.type = "button"; + copyButton.textContent = context.ui.t("history_copy_button", "Copy again"); + copyButton.addEventListener("click", function () { + replayHistory(item); + }); + + const deleteButton = document.createElement("button"); + deleteButton.className = "secondary-button"; + deleteButton.type = "button"; + deleteButton.textContent = context.ui.t("history_delete_button", "Delete"); + deleteButton.addEventListener("click", function () { + deleteHistoryItem(item.id); + }); + + const fullText = document.createElement("pre"); + fullText.className = "history-fulltext"; + fullText.hidden = true; + fullText.textContent = item.text; + + const expandButton = document.createElement("button"); + expandButton.className = "secondary-button"; + expandButton.type = "button"; + expandButton.textContent = context.ui.t("history_expand_button", "View full text"); + expandButton.addEventListener("click", function () { + const isHidden = fullText.hidden; + fullText.hidden = !isHidden; + expandButton.textContent = isHidden + ? context.ui.t("history_collapse_button", "Hide full text") + : context.ui.t("history_expand_button", "View full text"); + }); + + actions.appendChild(copyButton); + actions.appendChild(expandButton); + actions.appendChild(deleteButton); + top.appendChild(content); + wrapper.appendChild(top); + wrapper.appendChild(actions); + wrapper.appendChild(fullText); + + return wrapper; + } + + function createHistoryMeta(item) { + const sourceMap = { + click: context.ui.t("copy_history_source_click", "Extension click"), + shortcut: context.ui.t("copy_history_source_shortcut", "Extension shortcut"), + history: context.ui.t("copy_history_source_history", "History replay"), + native: context.ui.t("copy_history_source_native", "Native copy"), + }; + const modeMap = { + copy: context.ui.t("history_filter_copy", "Copy"), + }; + const meta = document.createElement("div"); + meta.className = "history-meta"; + meta.appendChild(createHistoryChip(sourceMap[item.source] || item.source, "source-" + normalizeSourceClass(item.source))); + meta.appendChild(createHistoryChip(modeMap[item.mode] || item.mode, "mode-chip")); + + const timeChip = createHistoryChip(formatRelativeTime(item.createdAt), "time-chip"); + timeChip.title = formatAbsoluteTime(item.createdAt); + meta.appendChild(timeChip); + + const hostname = item.hostname || context.utils.getHostnameFromUrl(item.url || ""); + if (hostname) { + meta.appendChild(createHistoryChip(hostname, "domain-chip")); + } + + if (item.replayCount) { + meta.appendChild(createHistoryChip("Replay ×" + item.replayCount, "replay-chip")); + } + + return meta; + } + + async function replayHistory(item) { + try { + await navigator.clipboard.writeText(item.text); + context.ui.showStatus(context.ui.t("copy_history_recopied", "Copied from history")); + await recordReplayUsage(item, "historyReplayCopy"); + } catch (error) { + context.ui.showStatus(error.message || "Clipboard error"); + } + } + + async function deleteHistoryItem(historyId) { + const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); + const nextHistory = context.utils.deleteHistoryEntries(current.copyHistory, [historyId]); + context.state.selectedHistoryIds.delete(historyId); + await context.utils.safeStorageSet("local", { copyHistory: nextHistory }); + await renderHistoryAndAnalytics(); + context.ui.showStatus(context.ui.t("history_deleted", "History item deleted")); + } + + async function recordReplayUsage(item, analyticsType) { + const current = await context.utils.safeStorageGet("local", { + copyHistory: [], + copyAnalytics: context.utils.DEFAULT_ANALYTICS, + }); + const hostname = item.hostname || context.utils.getHostnameFromUrl(item.url || ""); + const nextHistory = context.utils.pushHistoryEntry(current.copyHistory, { + text: item.text, + snippet: item.snippet || item.text, + source: "history", + mode: "copy", + url: item.url || "", + hostname: hostname, + }, context.state.settings.copyHistoryLimit); + const nextAnalytics = context.utils.recordAnalyticsEvent(current.copyAnalytics, { + type: analyticsType, + hostname: hostname, + toastKind: "status", + }); + + await context.utils.safeStorageSet("local", { + copyHistory: nextHistory, + copyAnalytics: nextAnalytics, + }); + } + + async function clearHistory() { + await context.utils.safeStorageSet("local", { copyHistory: [] }); + context.state.selectedHistoryIds.clear(); + context.state.bulkSelectionMode = false; + await renderHistoryAndAnalytics(); + context.ui.showStatus(context.ui.t("copy_history_cleared", "History cleared")); + } + + async function resetAnalytics() { + await context.utils.safeStorageSet("local", { copyAnalytics: context.utils.DEFAULT_ANALYTICS }); + await renderHistoryAndAnalytics(); + context.ui.showStatus(context.ui.t("analytics_reset_done", "Analytics reset")); + } + + function enableBulkSelection() { + context.state.bulkSelectionMode = true; + context.state.selectedHistoryIds.clear(); + renderHistoryAndAnalytics(); + } + + function disableBulkSelection() { + context.state.bulkSelectionMode = false; + context.state.selectedHistoryIds.clear(); + updateBulkToolbar(); + renderHistoryAndAnalytics(); + context.ui.showStatus(context.ui.t("history_selection_cleared", "Selection cleared")); + } + + async function bulkDeleteHistory() { + if (!context.state.selectedHistoryIds.size) { + return; + } + const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); + const nextHistory = context.utils.deleteHistoryEntries(current.copyHistory, Array.from(context.state.selectedHistoryIds)); + await context.utils.safeStorageSet("local", { copyHistory: nextHistory }); + context.state.selectedHistoryIds.clear(); + context.state.bulkSelectionMode = false; + await renderHistoryAndAnalytics(); + context.ui.showStatus(context.ui.t("history_bulk_done", "Bulk action completed")); + } + + async function bulkReplayHistory() { + if (!context.state.selectedHistoryIds.size) { + return; + } + + const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); + const selectedItems = (Array.isArray(current.copyHistory) ? current.copyHistory : []).filter(function (item) { + return item && context.state.selectedHistoryIds.has(item.id); + }); + const combinedText = selectedItems.map(function (item) { return item.text; }).join("\n\n").trim(); + if (!combinedText) { + return; + } + + try { + await navigator.clipboard.writeText(combinedText); + context.ui.showStatus(context.ui.t("history_bulk_done", "Bulk action completed")); + } catch (error) { + context.ui.showStatus(error.message || "Clipboard error"); + } + } + + function updateBulkToolbar() { + const hasSelection = context.state.selectedHistoryIds.size > 0; + document.getElementById("history_bulk_toggle_button").hidden = context.state.bulkSelectionMode; + document.getElementById("history_bulk_cancel_button").hidden = !context.state.bulkSelectionMode; + document.getElementById("history_bulk_delete_button").hidden = !context.state.bulkSelectionMode; + document.getElementById("history_bulk_copy_button").hidden = !context.state.bulkSelectionMode; + document.getElementById("history_bulk_delete_button").disabled = !hasSelection; + document.getElementById("history_bulk_copy_button").disabled = !hasSelection; + } + + function getGroupLabel(group) { + if (context.state.historyFilters.group == "source") { + const sourceMap = { + click: context.ui.t("copy_history_source_click", "Extension click"), + shortcut: context.ui.t("copy_history_source_shortcut", "Extension shortcut"), + history: context.ui.t("copy_history_source_history", "History replay"), + native: context.ui.t("copy_history_source_native", "Native copy"), + }; + return sourceMap[group.key] || group.label; + } + + if (context.state.historyFilters.group == "date") { + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); + if (group.key == today) return "Today"; + if (group.key == yesterday) return "Yesterday"; + return "Earlier"; + } + + return group.label; + } + + return { + renderHistoryAndAnalytics, + enableBulkSelection, + disableBulkSelection, + bulkDeleteHistory, + bulkReplayHistory, + clearHistory, + resetAnalytics, + }; +} + +module.exports = { + createOptionsHistoryView, +}; diff --git a/src/options/index.js b/src/options/index.js index 8a57ade..8612087 100644 --- a/src/options/index.js +++ b/src/options/index.js @@ -1,32 +1,30 @@ (function () { const { createOptionsUi } = require("./ui.js"); const { createTabController } = require("./tabs.js"); - var utils = globalThis.CopyTextUtils; - var settings = utils ? utils.mergeSettings() : null; - var historyFilters = { - search: "", - source: "all", - mode: "all", - hostname: "all", - sort: "newest", - group: "none", - }; - var bulkSelectionMode = false; - var selectedHistoryIds = new Set(); - var statusTimerId; + const { createSettingsForm } = require("./settings-form.js"); + const { createDomainsController } = require("./domains.js"); + const { createAnalyticsView } = require("./analytics-view.js"); + const { createOptionsHistoryView } = require("./history-view.js"); + const utils = globalThis.CopyTextUtils; if (!utils) { console.error("CopyTextUtils is not available."); return; } - var ui = createOptionsUi({ - utils: utils, - getSettings: function () { - return settings; + const state = { + settings: utils.mergeSettings(), + historyFilters: { + search: "", + source: "all", + mode: "all", + hostname: "all", + sort: "newest", + group: "none", }, - }); - var tabController = createTabController(); + bulkSelectionMode: false, + selectedHistoryIds: new Set(), + }; function isExtensionUsable() { return utils.isExtensionContextValid(); @@ -41,6 +39,23 @@ return false; } + const context = { + utils, + state, + isExtensionUsable, + reportOptionsError, + getSettings: function () { + return state.settings; + }, + }; + + context.ui = createOptionsUi(context); + context.tabs = createTabController(); + context.analytics = createAnalyticsView(context); + context.domains = createDomainsController(context); + context.settingsForm = createSettingsForm(context); + context.history = createOptionsHistoryView(context); + document.addEventListener("DOMContentLoaded", initializeOptions); async function initializeOptions() { @@ -49,11 +64,11 @@ } try { - await restoreOptions(); - ui.applyMessages(); + await context.settingsForm.restoreOptions(); + context.ui.applyMessages(); bindEvents(); - await renderExcludedDomains(); - await renderHistoryAndAnalytics(); + await context.domains.renderExcludedDomains(); + await context.history.renderHistoryAndAnalytics(); } catch (error) { if (reportOptionsError("Initializing the options page failed.", error)) { return; @@ -62,62 +77,134 @@ } function bindEvents() { - tabController.bindTabEvents(); + context.tabs.bindTabEvents(); - document.getElementById("meta_key").addEventListener("change", saveOptions); - document.getElementById("preview_enabled").addEventListener("change", saveOptions); - document.getElementById("avoid_editable").addEventListener("change", saveOptions); - document.getElementById("keyboard_shortcut_enabled").addEventListener("change", saveOptions); - document.getElementById("ui_language").addEventListener("change", handleLanguageChange); - document.getElementById("copy_history_limit").addEventListener("change", saveOptions); + document.getElementById("meta_key").addEventListener("change", function () { + context.settingsForm.saveOptions().catch(function (error) { + reportOptionsError("Saving options failed.", error); + }); + }); + document.getElementById("preview_enabled").addEventListener("change", function () { + context.settingsForm.saveOptions().catch(function (error) { + reportOptionsError("Saving options failed.", error); + }); + }); + document.getElementById("avoid_editable").addEventListener("change", function () { + context.settingsForm.saveOptions().catch(function (error) { + reportOptionsError("Saving options failed.", error); + }); + }); + document.getElementById("keyboard_shortcut_enabled").addEventListener("change", function () { + context.settingsForm.saveOptions().catch(function (error) { + reportOptionsError("Saving options failed.", error); + }); + }); + document.getElementById("ui_language").addEventListener("change", function () { + context.settingsForm.handleLanguageChange().catch(function (error) { + reportOptionsError("Changing options language failed.", error); + }); + }); + document.getElementById("copy_history_limit").addEventListener("change", function () { + context.settingsForm.saveOptions().catch(function (error) { + reportOptionsError("Saving options failed.", error); + }); + }); - document.getElementById("toast_duration").addEventListener("input", syncDurationControls); - document.getElementById("toast_duration_range").addEventListener("input", syncDurationControls); - document.getElementById("toast_duration").addEventListener("change", saveOptions); - document.getElementById("toast_duration_range").addEventListener("change", saveOptions); + document.getElementById("toast_duration").addEventListener("input", context.settingsForm.syncDurationControls); + document.getElementById("toast_duration_range").addEventListener("input", context.settingsForm.syncDurationControls); + document.getElementById("toast_duration").addEventListener("change", function () { + context.settingsForm.saveOptions().catch(function (error) { + reportOptionsError("Saving options failed.", error); + }); + }); + document.getElementById("toast_duration_range").addEventListener("change", function () { + context.settingsForm.saveOptions().catch(function (error) { + reportOptionsError("Saving options failed.", error); + }); + }); - document.getElementById("add_domain_button").addEventListener("click", addDomainFromInput); + document.getElementById("add_domain_button").addEventListener("click", function () { + context.domains.addDomainFromInput().catch(function (error) { + reportOptionsError("Adding an excluded domain failed.", error); + }); + }); document.getElementById("domain_input").addEventListener("keydown", function (event) { if (event.key == "Enter") { event.preventDefault(); - addDomainFromInput(); + context.domains.addDomainFromInput().catch(function (error) { + reportOptionsError("Adding an excluded domain failed.", error); + }); } }); - document.getElementById("apply_bulk_button").addEventListener("click", applyBulkDomains); + document.getElementById("apply_bulk_button").addEventListener("click", function () { + context.domains.applyBulkDomains().catch(function (error) { + reportOptionsError("Applying excluded domains failed.", error); + }); + }); document.getElementById("history_search").addEventListener("input", function (event) { - historyFilters.search = event.target.value; - renderHistoryAndAnalytics(); + state.historyFilters.search = event.target.value; + context.history.renderHistoryAndAnalytics().catch(function (error) { + reportOptionsError("Refreshing filtered history failed.", error); + }); }); document.getElementById("history_source_filter").addEventListener("change", function (event) { - historyFilters.source = event.target.value; - renderHistoryAndAnalytics(); + state.historyFilters.source = event.target.value; + context.history.renderHistoryAndAnalytics().catch(function (error) { + reportOptionsError("Refreshing filtered history failed.", error); + }); }); document.getElementById("history_mode_filter").addEventListener("change", function (event) { - historyFilters.mode = event.target.value; - renderHistoryAndAnalytics(); + state.historyFilters.mode = event.target.value; + context.history.renderHistoryAndAnalytics().catch(function (error) { + reportOptionsError("Refreshing filtered history failed.", error); + }); }); document.getElementById("history_domain_filter").addEventListener("change", function (event) { - historyFilters.hostname = event.target.value; - renderHistoryAndAnalytics(); + state.historyFilters.hostname = event.target.value; + context.history.renderHistoryAndAnalytics().catch(function (error) { + reportOptionsError("Refreshing filtered history failed.", error); + }); }); document.getElementById("history_sort").addEventListener("change", function (event) { - historyFilters.sort = event.target.value; - renderHistoryAndAnalytics(); + state.historyFilters.sort = event.target.value; + context.history.renderHistoryAndAnalytics().catch(function (error) { + reportOptionsError("Refreshing filtered history failed.", error); + }); }); document.getElementById("history_group").addEventListener("change", function (event) { - historyFilters.group = event.target.value; - renderHistoryAndAnalytics(); + state.historyFilters.group = event.target.value; + context.history.renderHistoryAndAnalytics().catch(function (error) { + reportOptionsError("Refreshing filtered history failed.", error); + }); }); - document.getElementById("history_bulk_toggle_button").addEventListener("click", enableBulkSelection); - document.getElementById("history_bulk_cancel_button").addEventListener("click", disableBulkSelection); - document.getElementById("history_bulk_delete_button").addEventListener("click", bulkDeleteHistory); + document.getElementById("history_bulk_toggle_button").addEventListener("click", function () { + context.history.enableBulkSelection(); + }); + document.getElementById("history_bulk_cancel_button").addEventListener("click", function () { + context.history.disableBulkSelection(); + }); + document.getElementById("history_bulk_delete_button").addEventListener("click", function () { + context.history.bulkDeleteHistory().catch(function (error) { + reportOptionsError("Bulk deleting history failed.", error); + }); + }); document.getElementById("history_bulk_copy_button").addEventListener("click", function () { - bulkReplayHistory(); + context.history.bulkReplayHistory().catch(function (error) { + reportOptionsError("Bulk replaying history failed.", error); + }); + }); + document.getElementById("clear_history_button").addEventListener("click", function () { + context.history.clearHistory().catch(function (error) { + reportOptionsError("Clearing history failed.", error); + }); + }); + document.getElementById("reset_analytics_button").addEventListener("click", function () { + context.history.resetAnalytics().catch(function (error) { + reportOptionsError("Resetting analytics failed.", error); + }); }); - document.getElementById("clear_history_button").addEventListener("click", clearHistory); - document.getElementById("reset_analytics_button").addEventListener("click", resetAnalytics); utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { if (!isExtensionUsable()) { @@ -125,693 +212,20 @@ } if (areaName == "sync") { - restoreOptions().then(function () { - applyMessages(); - renderExcludedDomains(); - renderHistoryAndAnalytics(); + context.settingsForm.restoreOptions().then(function () { + context.ui.applyMessages(); + context.domains.renderExcludedDomains(); + context.history.renderHistoryAndAnalytics(); }).catch(function (error) { reportOptionsError("Refreshing the options page after settings change failed.", error); }); } if (areaName == "local" && (changes.copyHistory || changes.copyAnalytics)) { - renderHistoryAndAnalytics().catch(function (error) { + context.history.renderHistoryAndAnalytics().catch(function (error) { reportOptionsError("Refreshing options history or analytics failed.", error); }); } }); } - - async function handleLanguageChange() { - await saveOptions(); - applyMessages(); - await renderExcludedDomains(); - await renderHistoryAndAnalytics(); - } - - async function restoreOptions() { - settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); - - document.getElementById("meta_key").value = settings.metaKey; - document.getElementById("preview_enabled").checked = settings.previewEnabled; - document.getElementById("avoid_editable").checked = settings.avoidEditable; - document.getElementById("keyboard_shortcut_enabled").checked = settings.keyboardShortcutEnabled; - document.getElementById("ui_language").value = settings.uiLanguage; - document.getElementById("copy_history_limit").value = String(settings.copyHistoryLimit); - document.getElementById("toast_duration").value = String(settings.toastDurationMs); - document.getElementById("toast_duration_range").value = String(settings.toastDurationMs); - document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); - document.getElementById("history_sort").value = historyFilters.sort; - document.getElementById("history_group").value = historyFilters.group; - } - - async function saveOptions() { - if (!isExtensionUsable()) { - return; - } - - settings = utils.mergeSettings({ - metaKey: document.getElementById("meta_key").value, - previewEnabled: document.getElementById("preview_enabled").checked, - avoidEditable: document.getElementById("avoid_editable").checked, - keyboardShortcutEnabled: document.getElementById("keyboard_shortcut_enabled").checked, - toastDurationMs: document.getElementById("toast_duration").value, - uiLanguage: document.getElementById("ui_language").value, - copyHistoryLimit: document.getElementById("copy_history_limit").value, - excludedDomains: document.getElementById("excluded_domains_bulk").value, - }); - - await utils.safeStorageSet("sync", settings); - showStatus(t("save_status_saved", "Saved")); - await renderExcludedDomains(); - } - - function syncDurationControls(event) { - var value = utils.normalizeToastDuration(event.target.value); - document.getElementById("toast_duration").value = String(value); - document.getElementById("toast_duration_range").value = String(value); - } - - async function addDomainFromInput() { - var input = document.getElementById("domain_input"); - var normalized = utils.normalizeDomain(input.value); - if (!normalized) { - return; - } - - settings.excludedDomains = utils.toggleDomain(settings.excludedDomains, normalized); - document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); - input.value = ""; - - await utils.safeStorageSet("sync", settings); - await renderExcludedDomains(); - showStatus(t("save_status_saved", "Saved")); - } - - async function applyBulkDomains() { - settings.excludedDomains = utils.normalizeExcludedDomains(document.getElementById("excluded_domains_bulk").value); - document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); - - await utils.safeStorageSet("sync", settings); - await renderExcludedDomains(); - showStatus(t("save_status_saved", "Saved")); - } - - async function renderExcludedDomains() { - var list = document.getElementById("excluded_domains_list"); - list.textContent = ""; - - if (!settings.excludedDomains.length) { - var empty = document.createElement("div"); - empty.className = "empty-state"; - empty.textContent = t("excluded_domains_empty", "No excluded domains yet."); - list.appendChild(empty); - return; - } - - settings.excludedDomains.forEach(function (domain) { - var item = document.createElement("div"); - item.className = "domain-item"; - - var row = document.createElement("div"); - row.className = "domain-row"; - - var name = document.createElement("div"); - name.className = "domain-name"; - name.textContent = domain; - - var remove = document.createElement("button"); - remove.className = "secondary-button"; - remove.type = "button"; - remove.textContent = t("domain_remove_button", "Remove"); - remove.addEventListener("click", async function () { - settings.excludedDomains = utils.toggleDomain(settings.excludedDomains, domain); - document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); - await utils.safeStorageSet("sync", settings); - await renderExcludedDomains(); - showStatus(t("save_status_saved", "Saved")); - }); - - row.appendChild(name); - row.appendChild(remove); - item.appendChild(row); - list.appendChild(item); - }); - } - - async function renderHistoryAndAnalytics() { - var current = await utils.safeStorageGet("local", { - copyHistory: [], - copyAnalytics: utils.DEFAULT_ANALYTICS, - }); - - var history = Array.isArray(current.copyHistory) ? current.copyHistory : []; - var analytics = utils.normalizeAnalytics(current.copyAnalytics); - - populateHistoryDomainFilter(history); - renderAnalytics(analytics); - renderHistory(history); - } - - function populateHistoryDomainFilter(history) { - var filter = document.getElementById("history_domain_filter"); - var currentValue = historyFilters.hostname; - var hosts = utils.getHistoryHostOptions(history); - - filter.textContent = ""; - - var allOption = document.createElement("option"); - allOption.value = "all"; - allOption.textContent = t("history_filter_all", "All"); - filter.appendChild(allOption); - - hosts.forEach(function (hostname) { - var option = document.createElement("option"); - option.value = hostname; - option.textContent = hostname; - filter.appendChild(option); - }); - - filter.value = hosts.includes(currentValue) ? currentValue : "all"; - historyFilters.hostname = filter.value; - } - - function renderAnalytics(analytics) { - document.getElementById("analytics_total_actions_value").textContent = String(analytics.totals.totalActions || 0); - document.getElementById("analytics_append_actions_value").textContent = String(analytics.totals.selectionCopies || 0); - document.getElementById("analytics_native_actions_value").textContent = String(analytics.totals.nativeCopies || 0); - document.getElementById("analytics_shortcut_actions_value").textContent = String(analytics.totals.shortcuts || 0); - document.getElementById("analytics_blocked_actions_value").textContent = String((analytics.totals.excludedBlocked || 0) + (analytics.totals.editableSkipped || 0)); - document.getElementById("analytics_toast_events_value").textContent = String( - (analytics.toastCounts.copied || 0) - + (analytics.toastCounts.status || 0) - ); - - var list = document.getElementById("analytics_top_domains_list"); - list.textContent = ""; - - var topDomains = utils.getTopDomainStats(analytics, 5); - if (!topDomains.length) { - var empty = document.createElement("div"); - empty.className = "empty-state"; - empty.textContent = t("analytics_empty_domains", "No domain activity yet."); - list.appendChild(empty); - return; - } - - topDomains.forEach(function (item) { - var card = document.createElement("div"); - card.className = "domain-item"; - - var row = document.createElement("div"); - row.className = "domain-row"; - - var name = document.createElement("div"); - name.className = "domain-name"; - name.textContent = item.hostname; - - var meta = document.createElement("div"); - meta.className = "history-meta"; - meta.appendChild(createHistoryChip(t("analytics_total_actions", "Total actions") + ": " + item.totalActions, "domain-metric")); - meta.appendChild(createHistoryChip(t("analytics_shortcut_actions", "Shortcut usage") + ": " + item.shortcuts, "domain-metric")); - - row.appendChild(name); - card.appendChild(row); - card.appendChild(meta); - list.appendChild(card); - }); - } - - function renderHistory(history) { - var filteredHistory = utils.filterHistoryEntries(history, historyFilters); - var sortedHistory = utils.sortHistoryEntries(filteredHistory, historyFilters.sort); - var groupedHistory = utils.groupHistoryEntries(sortedHistory, historyFilters.group); - var list = document.getElementById("options_history_list"); - list.textContent = ""; - - if (!sortedHistory.length) { - var empty = document.createElement("div"); - empty.className = "empty-state"; - empty.textContent = history.length - ? t("history_no_results", "No matching history entries.") - : t("copy_history_empty", "No copied items yet."); - list.appendChild(empty); - updateBulkToolbar(); - return; - } - - groupedHistory.forEach(function (group) { - if (historyFilters.group != "none") { - var groupWrap = document.createElement("div"); - groupWrap.className = "history-group"; - - var groupLabel = document.createElement("div"); - groupLabel.className = "history-group-label"; - groupLabel.textContent = getGroupLabel(group); - groupWrap.appendChild(groupLabel); - - group.entries.forEach(function (item) { - groupWrap.appendChild(createHistoryItem(item)); - }); - list.appendChild(groupWrap); - return; - } - - group.entries.forEach(function (item) { - list.appendChild(createHistoryItem(item)); - }); - }); - - updateBulkToolbar(); - } - - function createHistoryItem(item) { - var wrapper = document.createElement("div"); - wrapper.className = "history-item"; - wrapper.classList.toggle("selected", selectedHistoryIds.has(item.id)); - - var top = document.createElement("div"); - top.className = "history-top"; - - var content = document.createElement("div"); - content.className = "field-group"; - - if (bulkSelectionMode) { - var selector = document.createElement("input"); - selector.type = "checkbox"; - selector.checked = selectedHistoryIds.has(item.id); - selector.addEventListener("change", function () { - if (selector.checked) { - selectedHistoryIds.add(item.id); - } else { - selectedHistoryIds.delete(item.id); - } - updateBulkToolbar(); - wrapper.classList.toggle("selected", selectedHistoryIds.has(item.id)); - }); - content.appendChild(selector); - } - - var snippet = document.createElement("div"); - snippet.className = "history-snippet"; - snippet.textContent = item.snippet || item.text; - - var meta = createHistoryMeta(item); - - content.appendChild(snippet); - content.appendChild(meta); - - var actions = document.createElement("div"); - actions.className = "history-actions"; - - var copyButton = document.createElement("button"); - copyButton.className = "secondary-button"; - copyButton.type = "button"; - copyButton.textContent = t("history_copy_button", "Copy again"); - copyButton.addEventListener("click", function () { - replayHistory(item); - }); - - var deleteButton = document.createElement("button"); - deleteButton.className = "secondary-button"; - deleteButton.type = "button"; - deleteButton.textContent = t("history_delete_button", "Delete"); - deleteButton.addEventListener("click", function () { - deleteHistoryItem(item.id); - }); - - var fullText = document.createElement("pre"); - fullText.className = "history-fulltext"; - fullText.hidden = true; - fullText.textContent = item.text; - - var expandButton = document.createElement("button"); - expandButton.className = "secondary-button"; - expandButton.type = "button"; - expandButton.textContent = t("history_expand_button", "View full text"); - expandButton.addEventListener("click", function () { - var isHidden = fullText.hidden; - fullText.hidden = !isHidden; - expandButton.textContent = isHidden - ? t("history_collapse_button", "Hide full text") - : t("history_expand_button", "View full text"); - }); - - actions.appendChild(copyButton); - actions.appendChild(expandButton); - actions.appendChild(deleteButton); - top.appendChild(content); - wrapper.appendChild(top); - wrapper.appendChild(actions); - wrapper.appendChild(fullText); - - return wrapper; - } - - function createHistoryMeta(item) { - var sourceMap = { - click: t("copy_history_source_click", "Extension click"), - shortcut: t("copy_history_source_shortcut", "Extension shortcut"), - history: t("copy_history_source_history", "History replay"), - native: t("copy_history_source_native", "Native copy"), - }; - var modeMap = { - copy: t("history_filter_copy", "Copy"), - }; - var meta = document.createElement("div"); - meta.className = "history-meta"; - meta.appendChild(createHistoryChip(sourceMap[item.source] || item.source, "source-" + normalizeSourceClass(item.source))); - meta.appendChild(createHistoryChip(modeMap[item.mode] || item.mode, "mode-chip")); - - var timeChip = createHistoryChip(formatRelativeTime(item.createdAt), "time-chip"); - timeChip.title = formatAbsoluteTime(item.createdAt); - meta.appendChild(timeChip); - - var hostname = item.hostname || utils.getHostnameFromUrl(item.url || ""); - if (hostname) { - meta.appendChild(createHistoryChip(hostname, "domain-chip")); - } - - if (item.replayCount) { - meta.appendChild(createHistoryChip("Replay ×" + item.replayCount, "replay-chip")); - } - - return meta; - } - - function formatRelativeTime(value) { - var delta = Date.now() - Number(value || 0); - if (!Number.isFinite(delta) || delta < 60000) { - return t("history_recency_now", "Just now"); - } - - var minutes = Math.round(delta / 60000); - if (minutes < 60) { - return minutes + "m ago"; - } - - var hours = Math.round(minutes / 60); - if (hours < 24) { - return hours + "h ago"; - } - - return Math.round(hours / 24) + "d ago"; - } - - function formatAbsoluteTime(value) { - var timestamp = Number(value || 0); - if (!Number.isFinite(timestamp) || !timestamp) { - return ""; - } - - try { - return new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }).format(new Date(timestamp)); - } catch (error) { - return new Date(timestamp).toLocaleString(); - } - } - - function createHistoryChip(label, extraClass) { - var chip = document.createElement("span"); - chip.className = "history-chip" + (extraClass ? " " + extraClass : ""); - chip.textContent = label; - return chip; - } - - function normalizeSourceClass(source) { - if (source == "click") return "extension"; - if (source == "shortcut") return "shortcut"; - if (source == "history") return "history"; - if (source == "native") return "native"; - return "unknown"; - } - - async function replayHistory(item) { - try { - await navigator.clipboard.writeText(item.text); - showStatus(t("copy_history_recopied", "Copied from history")); - await recordReplayUsage(item, "historyReplayCopy"); - } catch (error) { - showStatus(error.message || "Clipboard error"); - } - } - - async function deleteHistoryItem(historyId) { - var current = await utils.safeStorageGet("local", { copyHistory: [] }); - var nextHistory = utils.deleteHistoryEntries(current.copyHistory, [historyId]); - selectedHistoryIds.delete(historyId); - await utils.safeStorageSet("local", { copyHistory: nextHistory }); - await renderHistoryAndAnalytics(); - showStatus(t("history_deleted", "History item deleted")); - } - - async function recordReplayUsage(item, analyticsType) { - var current = await utils.safeStorageGet("local", { - copyHistory: [], - copyAnalytics: utils.DEFAULT_ANALYTICS, - }); - var hostname = item.hostname || utils.getHostnameFromUrl(item.url || ""); - var nextHistory = utils.pushHistoryEntry(current.copyHistory, { - text: item.text, - snippet: item.snippet || item.text, - source: "history", - mode: "copy", - url: item.url || "", - hostname: hostname, - }, settings.copyHistoryLimit); - var nextAnalytics = utils.recordAnalyticsEvent(current.copyAnalytics, { - type: analyticsType, - hostname: hostname, - toastKind: "status", - }); - - await utils.safeStorageSet("local", { - copyHistory: nextHistory, - copyAnalytics: nextAnalytics, - }); - } - - async function clearHistory() { - await utils.safeStorageSet("local", { copyHistory: [] }); - selectedHistoryIds.clear(); - bulkSelectionMode = false; - await renderHistoryAndAnalytics(); - showStatus(t("copy_history_cleared", "History cleared")); - } - - async function resetAnalytics() { - await utils.safeStorageSet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); - await renderHistoryAndAnalytics(); - showStatus(t("analytics_reset_done", "Analytics reset")); - } - - function applyMessages() { - setText("settings_eyebrow", "Settings"); - setText("settings_title", "Copy text with Alt-Click"); - setText("settings_intro", "Configure how copy gestures, hover previews, safe-mode protections, and history behave across websites."); - setText("tab_general", "General"); - setText("tab_sites", "Sites"); - setText("tab_feedback", "Feedback"); - setText("tab_language", "Language"); - setText("tab_history", "History"); - setText("general_section_title", "General controls"); - setText("meta_key_label", "Copy operation"); - setText("meta_key_help", "Hold your copy modifier and click to copy page content quickly."); - setText("preview_enabled_label", "Hover preview"); - setText("preview_enabled_help", "Show the dashed outline overlay while holding the copy modifier."); - setText("avoid_editable_label", "Skip editable apps"); - setText("avoid_editable_help", "Avoid contenteditable editors and rich text surfaces where copy gestures might be disruptive."); - setText("keyboard_shortcut_enabled_label", "Keyboard shortcut mode"); - setText("keyboard_shortcut_enabled_help", "Allow the browser shortcut to copy the hovered target or the focused element without clicking."); - setText("keyboard_shortcut_hint", "You can customize the extension shortcut in chrome://extensions/shortcuts."); - setText("sites_section_title", "Excluded domains"); - setText("excluded_domains_add_label", "Add domain"); - setText("add_domain_button", "Add domain"); - document.getElementById("domain_input").placeholder = t("excluded_domains_add_placeholder", "example.com"); - setText("excluded_domains_import_label", "Bulk paste domains"); - setText("excluded_domains_import_help", "Paste one domain per line and click Apply."); - setText("apply_bulk_button", "Apply list"); - setText("excluded_domains_help", "Excluded domains disable both hover previews and copy actions."); - setText("feedback_title", "Feedback behavior"); - setText("toast_duration_label", "Feedback duration"); - setText("toast_duration_help", "Custom duration for the floating copy feedback."); - setText("toast_duration_unit", "ms"); - setText("language_title", "Language"); - setText("ui_language_label", "Extension language"); - setText("ui_language_help", "Auto follows the browser locale. English and Vietnamese are available as manual overrides."); - setText("ui_language_auto", "Auto"); - setText("ui_language_en", "English"); - setText("ui_language_vi", "Tiếng Việt"); - setText("history_title", "Copy history"); - setText("analytics_title", "Local analytics"); - setText("reset_analytics_button", "Reset analytics"); - setText("analytics_total_actions", "Total actions"); - setText("analytics_append_actions", "Selection-first copies"); - setText("analytics_native_actions", "Native copies"); - setText("analytics_shortcut_actions", "Shortcut usage"); - setText("analytics_blocked_actions", "Blocked attempts"); - setText("analytics_toast_events", "Toast events"); - setText("analytics_top_domains", "Top domains"); - setText("history_search_label", "Search history"); - document.getElementById("history_search").placeholder = t("history_search_placeholder", "Search copied text or hostname"); - setText("history_filter_source_label", "Source"); - setText("history_filter_mode_label", "Mode"); - setText("history_filter_domain_label", "Domain"); - setText("history_sort_label", "Sort by"); - setText("history_group_label", "Group by"); - setHistorySelectOptions(); - setText("copy_history_limit_label", "Saved history items"); - setText("copy_history_limit_help", "How many recent copied entries should be kept locally."); - setText("history_bulk_toggle_button", "Select items"); - setText("history_bulk_cancel_button", "Cancel selection"); - setText("history_bulk_copy_button", "Copy selected"); - setText("history_bulk_delete_button", "Delete selected"); - setText("clear_history_button", "Clear history"); - } - - function setHistorySelectOptions() { - setSelectOptionText("history_source_filter", "all", t("history_filter_all", "All")); - setSelectOptionText("history_source_filter", "click", t("history_filter_click", "Click")); - setSelectOptionText("history_source_filter", "shortcut", t("history_filter_shortcut", "Shortcut")); - setSelectOptionText("history_source_filter", "history", t("history_filter_history", "History replay")); - setSelectOptionText("history_source_filter", "native", t("history_filter_native", "Native copy")); - setSelectOptionText("history_mode_filter", "all", t("history_filter_all", "All")); - setSelectOptionText("history_mode_filter", "copy", t("history_filter_copy", "Copy")); - setSelectOptionText("history_domain_filter", "all", t("history_filter_all", "All")); - setSelectOptionText("history_sort", "newest", t("history_sort_newest", "Newest")); - setSelectOptionText("history_sort", "oldest", t("history_sort_oldest", "Oldest")); - setSelectOptionText("history_sort", "replayed", t("history_sort_replayed", "Most replayed")); - setSelectOptionText("history_group", "none", t("history_group_none", "None")); - setSelectOptionText("history_group", "domain", t("history_group_domain", "Domain")); - setSelectOptionText("history_group", "source", t("history_group_source", "Source")); - setSelectOptionText("history_group", "date", t("history_group_date", "Date")); - } - - function enableBulkSelection() { - bulkSelectionMode = true; - selectedHistoryIds.clear(); - renderHistoryAndAnalytics(); - } - - function disableBulkSelection() { - bulkSelectionMode = false; - selectedHistoryIds.clear(); - updateBulkToolbar(); - renderHistoryAndAnalytics(); - showStatus(t("history_selection_cleared", "Selection cleared")); - } - - async function bulkDeleteHistory() { - if (!selectedHistoryIds.size) { - return; - } - var current = await utils.safeStorageGet("local", { copyHistory: [] }); - var nextHistory = utils.deleteHistoryEntries(current.copyHistory, Array.from(selectedHistoryIds)); - await utils.safeStorageSet("local", { copyHistory: nextHistory }); - selectedHistoryIds.clear(); - bulkSelectionMode = false; - await renderHistoryAndAnalytics(); - showStatus(t("history_bulk_done", "Bulk action completed")); - } - - async function bulkReplayHistory() { - if (!selectedHistoryIds.size) { - return; - } - - var current = await utils.safeStorageGet("local", { copyHistory: [] }); - var selectedItems = (Array.isArray(current.copyHistory) ? current.copyHistory : []).filter(function (item) { - return item && selectedHistoryIds.has(item.id); - }); - var combinedText = selectedItems.map(function (item) { return item.text; }).join("\n\n").trim(); - if (!combinedText) { - return; - } - - try { - await navigator.clipboard.writeText(combinedText); - showStatus(t("history_bulk_done", "Bulk action completed")); - } catch (error) { - showStatus(error.message || "Clipboard error"); - } - } - - function updateBulkToolbar() { - var hasSelection = selectedHistoryIds.size > 0; - document.getElementById("history_bulk_toggle_button").hidden = bulkSelectionMode; - document.getElementById("history_bulk_cancel_button").hidden = !bulkSelectionMode; - document.getElementById("history_bulk_delete_button").hidden = !bulkSelectionMode; - document.getElementById("history_bulk_copy_button").hidden = !bulkSelectionMode; - document.getElementById("history_bulk_delete_button").disabled = !hasSelection; - document.getElementById("history_bulk_copy_button").disabled = !hasSelection; - } - - function getGroupLabel(group) { - if (historyFilters.group == "source") { - var sourceMap = { - click: t("copy_history_source_click", "Extension click"), - shortcut: t("copy_history_source_shortcut", "Extension shortcut"), - history: t("copy_history_source_history", "History replay"), - native: t("copy_history_source_native", "Native copy"), - }; - return sourceMap[group.key] || group.label; - } - - if (historyFilters.group == "date") { - var today = new Date().toISOString().slice(0, 10); - var yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); - if (group.key == today) return "Today"; - if (group.key == yesterday) return "Yesterday"; - return "Earlier"; - } - - return group.label; - } - - function setSelectOptionText(selectId, value, text) { - var select = document.getElementById(selectId); - var option = select && select.querySelector('option[value="' + value + '"]'); - if (option) { - option.textContent = text; - } - } - - function activateTab(tabId) { - document.querySelectorAll(".tab-button").forEach(function (button) { - var isActive = button.dataset.tab == tabId; - button.classList.toggle("active", isActive); - button.setAttribute("aria-selected", String(isActive)); - button.setAttribute("tabindex", isActive ? "0" : "-1"); - }); - - document.querySelectorAll(".tab-panel").forEach(function (panel) { - panel.classList.toggle("active", panel.dataset.panel == tabId); - }); - } - - function showStatus(message) { - var status = document.getElementById("save_status"); - status.textContent = message; - clearTimeout(statusTimerId); - statusTimerId = setTimeout(function () { - status.textContent = ""; - }, 1800); - } - - function setText(id, fallback) { - var element = document.getElementById(id); - if (element) { - element.textContent = t(id, fallback); - } - } - - function t(key, fallback) { - return utils.translate(settings, key, chrome.i18n.getMessage(key) || fallback, chrome.i18n.getUILanguage()); - } })(); diff --git a/src/options/settings-form.js b/src/options/settings-form.js new file mode 100644 index 0000000..a8b6dbe --- /dev/null +++ b/src/options/settings-form.js @@ -0,0 +1,64 @@ +function createSettingsForm(context) { + async function restoreOptions() { + const settings = context.utils.mergeSettings(await context.utils.safeStorageGet("sync", context.utils.DEFAULT_SETTINGS)); + context.state.settings = settings; + + document.getElementById("meta_key").value = settings.metaKey; + document.getElementById("preview_enabled").checked = settings.previewEnabled; + document.getElementById("avoid_editable").checked = settings.avoidEditable; + document.getElementById("keyboard_shortcut_enabled").checked = settings.keyboardShortcutEnabled; + document.getElementById("ui_language").value = settings.uiLanguage; + document.getElementById("copy_history_limit").value = String(settings.copyHistoryLimit); + document.getElementById("toast_duration").value = String(settings.toastDurationMs); + document.getElementById("toast_duration_range").value = String(settings.toastDurationMs); + document.getElementById("excluded_domains_bulk").value = settings.excludedDomains.join("\n"); + document.getElementById("history_sort").value = context.state.historyFilters.sort; + document.getElementById("history_group").value = context.state.historyFilters.group; + } + + async function saveOptions() { + if (!context.isExtensionUsable()) { + return; + } + + const settings = context.utils.mergeSettings({ + metaKey: document.getElementById("meta_key").value, + previewEnabled: document.getElementById("preview_enabled").checked, + avoidEditable: document.getElementById("avoid_editable").checked, + keyboardShortcutEnabled: document.getElementById("keyboard_shortcut_enabled").checked, + toastDurationMs: document.getElementById("toast_duration").value, + uiLanguage: document.getElementById("ui_language").value, + copyHistoryLimit: document.getElementById("copy_history_limit").value, + excludedDomains: document.getElementById("excluded_domains_bulk").value, + }); + + context.state.settings = settings; + await context.utils.safeStorageSet("sync", settings); + context.ui.showStatus(context.ui.t("save_status_saved", "Saved")); + await context.domains.renderExcludedDomains(); + } + + function syncDurationControls(event) { + const value = context.utils.normalizeToastDuration(event.target.value); + document.getElementById("toast_duration").value = String(value); + document.getElementById("toast_duration_range").value = String(value); + } + + async function handleLanguageChange() { + await saveOptions(); + context.ui.applyMessages(); + await context.domains.renderExcludedDomains(); + await context.history.renderHistoryAndAnalytics(); + } + + return { + restoreOptions, + saveOptions, + syncDurationControls, + handleLanguageChange, + }; +} + +module.exports = { + createSettingsForm, +}; From a2849477678e19d5d5acfb1032f0d5605714ed93 Mon Sep 17 00:00:00 2001 From: VET Date: Tue, 19 May 2026 10:28:53 +0700 Subject: [PATCH 05/12] Stabilize content helper boundaries for release The content helper had become the last broad runtime bucket after the Chrome-only release work. This keeps the createContentHelpers facade stable for bootstrap and events while moving targeting, extraction, overlay, persistence, and clipboard concerns into focused modules. The pass also locks the behavior that matters for this split with Playwright coverage around rich target extraction, table TSV export, selection priority, shortcut copy, excluded domains, and reload recovery. Constraint: Chrome-only contract and dist/chrome remain the release surface Constraint: No product scope expansion or root-authored runtime JS Rejected: Rewrite bootstrap/events around new imports | higher blast radius with no user-facing value Rejected: Keep helpers.js as a large bucket | future edits would keep mixing unrelated runtime responsibilities Confidence: high Scope-risk: moderate Directive: Keep createContentHelpers as the content facade unless bootstrap/events are refactored in the same verified pass Tested: npm run verify:release Not-tested: Human manual acceptance in visible Chrome --- docs/browser-manual-test-checklist.md | 8 +- src/content/clipboard.js | 95 ++ src/content/extraction.js | 305 ++++++ src/content/helpers.js | 1311 +------------------------ src/content/overlay.js | 302 ++++++ src/content/persistence.js | 85 ++ src/content/targeting.js | 470 +++++++++ tests/e2e/extension.spec.js | 63 ++ 8 files changed, 1352 insertions(+), 1287 deletions(-) create mode 100644 src/content/clipboard.js create mode 100644 src/content/extraction.js create mode 100644 src/content/overlay.js create mode 100644 src/content/persistence.js create mode 100644 src/content/targeting.js diff --git a/docs/browser-manual-test-checklist.md b/docs/browser-manual-test-checklist.md index 6cc01b9..3b65b4e 100644 --- a/docs/browser-manual-test-checklist.md +++ b/docs/browser-manual-test-checklist.md @@ -3,7 +3,7 @@ ## Setup 1. Run `npm run validate`. -2. Load the extension unpacked in Chrome. +2. Load the extension unpacked in Chrome from `C:\Users\VET\copy-text-without-selecting\dist\chrome`. 3. Open `chrome://extensions/shortcuts` and confirm the extension commands are visible. 4. Serve the fixtures with a local HTTP server: @@ -47,6 +47,7 @@ python -m http.server 4173 ## Basic Copy +- Automated by Playwright: plain paragraph, link Markdown, input, textarea, and select copy flows. - [ ] Plain paragraph copies visible text - [ ] Native `Ctrl+C` / browser copy on selected text creates a history item with native source - [ ] Link copies as Markdown `[text](href)` @@ -59,6 +60,7 @@ python -m http.server 4173 ## Selection-first +- Automated by Playwright: selected paragraph text wins over the broad target. - [ ] Selecting part of a paragraph copies only the selected text - [ ] Selecting part of a link copies only the selected text, not the whole link container - [ ] Selecting part of a code block ignores decorative line-number UI @@ -67,6 +69,7 @@ python -m http.server 4173 ## Table TSV +- Automated by Playwright: table TSV export excludes hidden cells and preserves tab separators in history. - [ ] Clicking inside a table without a text selection exports the whole table as TSV - [ ] Hidden cells are excluded from TSV output - [ ] Multiline content inside a cell is normalized into spreadsheet-friendly text @@ -86,12 +89,14 @@ python -m http.server 4173 ## Keyboard Shortcut Mode +- Automated by Playwright: shortcut message path copies the hovered fixture target. - [ ] Shortcut copies hovered target without clicking - [ ] Shortcut copies focused element when nothing is hovered - [ ] Disabling keyboard shortcut mode in settings suppresses command behavior ## Reload / Update +- Automated by Playwright: extension reload + page refresh still copies and does not spam `Extension context invalidated`. - [ ] Reloading the extension does not cause repeated `Extension context invalidated` console spam on refreshed pages - [ ] After extension reload + page refresh, copy still works on all fixture pages - [ ] Popup, history, and shortcut behavior still work after extension reload @@ -108,6 +113,7 @@ python -m http.server 4173 ## Excluded Domains +- Automated by Playwright: excluding the fixture host blocks copy. - [ ] Excluding `localhost` (or another test host) disables both preview and copy - [ ] Including the host again restores normal behavior - [ ] Subdomain matching works as expected for excluded roots diff --git a/src/content/clipboard.js b/src/content/clipboard.js new file mode 100644 index 0000000..be01460 --- /dev/null +++ b/src/content/clipboard.js @@ -0,0 +1,95 @@ +function createContentClipboard(context, targeting, extraction, overlay, persistence) { + const hoverState = context.state.hoverState; + + async function copyCommand(clickedElement, source, options) { + const precisionTarget = targeting.resolvePrecisionTarget(clickedElement, options); + if (!precisionTarget) { + return; + } + await executePrecisionCopy(precisionTarget, source); + } + + async function executePrecisionCopy(precisionTarget, source) { + const text = extraction.getText(precisionTarget); + if (!text) { + return; + } + + const htmlContent = extraction.getHtmlContent(precisionTarget); + const result = "copied"; + await copy(text, htmlContent); + + hoverState.scopeLevel = 0; + hoverState.scopeAnchorClientX = null; + hoverState.scopeAnchorClientY = null; + + overlay.showCopyFeedback(precisionTarget.rect, result); + const analyticsEvents = [{ + type: persistence.getAnalyticsTypeForResult(result, source || "click", precisionTarget.kind == "selection"), + hostname: window.location.hostname, + toastKind: persistence.getToastAnalyticsKind(result), + }]; + if ((source || "click") == "shortcut") { + analyticsEvents.push({ + type: "shortcut", + hostname: window.location.hostname, + }); + } + persistence.saveAnalyticsEvents(analyticsEvents); + await persistence.saveHistory(text, result, source || "click", precisionTarget.kind == "selection"); + } + + async function copy(text, htmlContent) { + if (htmlContent && navigator.clipboard && typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined") { + try { + const textBlob = new Blob([text], { type: "text/plain" }); + const htmlBlob = new Blob([htmlContent], { type: "text/html" }); + await navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": textBlob, + "text/html": htmlBlob + }) + ]); + return; + } catch (error) { + console.warn("ClipboardItem write failed, falling back to writeText.", error); + } + } + + if (navigator.clipboard && typeof navigator.clipboard.writeText == "function") { + try { + await navigator.clipboard.writeText(text); + return; + } catch (error) { + console.warn("Clipboard writeText failed, using fallback copy.", error); + } + } + + const container = document.body || document.documentElement; + const textArea = document.createElement("textarea"); + textArea.style.cssText = "position:absolute;left:-100%;top:0;"; + + try { + container.appendChild(textArea); + textArea.value = text; + textArea.select(); + context.state.suppressNativeCopyTracking = true; + if (!document.execCommand("copy")) { + console.error("Copy failed."); + } + } finally { + context.state.suppressNativeCopyTracking = false; + textArea.remove(); + } + } + + return { + copyCommand, + executePrecisionCopy, + copy, + }; +} + +module.exports = { + createContentClipboard, +}; diff --git a/src/content/extraction.js b/src/content/extraction.js new file mode 100644 index 0000000..11f57f3 --- /dev/null +++ b/src/content/extraction.js @@ -0,0 +1,305 @@ +function createContentExtraction(context, targeting) { + function getText(target) { + const extractionContext = resolveExtractionContext(target); + if (!extractionContext) { + return ""; + } + + let raw; + switch (extractionContext.kind) { + case "selection": + raw = extractionContext.text; + break; + case "scope": + raw = extractionContext.text || collectVisibleText(extractionContext.node).trim(); + break; + case "table": + return sanitizeTableText(extractTableAsTsv(extractionContext.table)); + case "code": + raw = extractCodeText(extractionContext.container); + break; + case "list": + raw = extractListAsText(extractionContext.container); + break; + case "image": + raw = getImageText(extractionContext.element); + break; + case "link": + raw = "[" + (collectVisibleText(extractionContext.anchor).trim() || extractionContext.anchor.href) + "](" + extractionContext.anchor.href + ")"; + break; + case "control": + raw = getPlainText(extractionContext.element).trim(); + break; + default: + raw = collectVisibleText(extractionContext.node).trim(); + } + + return sanitizeText(raw); + } + + function resolveExtractionContext(target) { + if (!target) { + return null; + } + + if (target.kind == "selection" || target.kind == "scope") { + return target; + } + + const node = target.node || target.element || targeting.getElementNode(target); + const element = targeting.getElementNode(node); + if (!element) { + return null; + } + + if (element.nodeName.toUpperCase() == "IMG") { + return { kind: "image", element: element }; + } + + const table = typeof element.closest == "function" ? element.closest("table") : null; + if (table) { + return { kind: "table", table: table }; + } + + const codeContainer = typeof element.closest == "function" ? element.closest("pre, code") : null; + if (codeContainer) { + return { kind: "code", container: codeContainer }; + } + + const listContainer = typeof element.closest == "function" ? element.closest("ul, ol") : null; + if (listContainer) { + return { kind: "list", container: listContainer }; + } + + const anchor = typeof element.closest == "function" ? element.closest("a[href]") : null; + if (anchor) { + return { kind: "link", anchor: anchor }; + } + + const tagName = element.nodeName.toUpperCase(); + if (tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "SELECT") { + return { kind: "control", element: element }; + } + + return { kind: "text", node: node }; + } + + function getExtractionNode(node) { + const element = targeting.getElementNode(node); + if (!element) { + return null; + } + return element; + } + + function getImageText(node) { + return node.getAttribute("src") || node.getAttribute("alt") || ""; + } + + function collectVisibleText(node) { + if (!node) { + return ""; + } + + if (node.nodeType == Node.TEXT_NODE) { + return targeting.isNodeVisible(node) ? String(node.textContent || "") : ""; + } + + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { + acceptNode: function (textNode) { + return targeting.isNodeVisible(textNode) && String(textNode.textContent || "").trim() + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + } + }); + + const parts = []; + let current = walker.nextNode(); + while (current) { + parts.push(String(current.textContent || "").trim()); + current = walker.nextNode(); + } + + return parts.join(" ").replace(/\s+/g, " ").trim(); + } + + function extractTableAsTsv(table) { + return Array.from(table.rows || []).map(function (row) { + return Array.from(row.cells || []).filter(targeting.isNodeVisible).map(function (cell) { + return collectVisibleText(cell).replace(/\s*\n+\s*/g, " ").trim(); + }).join("\t"); + }).filter(Boolean).join("\n"); + } + + function extractCodeText(container) { + const clone = container.cloneNode(true); + Array.from(clone.querySelectorAll("[aria-hidden='true'], .line-numbers, .line-number, .lineno, .gutter, .blob-num")).forEach(function (element) { + element.remove(); + }); + return collectVisibleText(clone).replace(/\u00a0/g, " ").trim(); + } + + function extractListAsText(container) { + const isOrdered = container.nodeName.toUpperCase() === "OL"; + const items = Array.from(container.children).filter(function (child) { + return child.nodeName.toUpperCase() === "LI" && targeting.isNodeVisible(child); + }); + + return items.map(function (li, index) { + const nestedList = li.querySelector("ul, ol"); + let mainText = ""; + + if (nestedList) { + const clone = li.cloneNode(true); + Array.from(clone.querySelectorAll("ul, ol")).forEach(function (nested) { + nested.remove(); + }); + mainText = collectVisibleText(clone).trim(); + } else { + mainText = collectVisibleText(li).trim(); + } + + const prefix = isOrdered ? (index + 1) + ". " : "- "; + let line = prefix + mainText; + + if (nestedList) { + const nestedLines = extractListAsText(nestedList).split("\n").map(function (nestedLine) { + return " " + nestedLine; + }).join("\n"); + line += "\n" + nestedLines; + } + + return line; + }).join("\n"); + } + + function sanitizeText(text) { + return String(text || "") + .replace(/\u200B/g, "") + .replace(/\u200C/g, "") + .replace(/\u200D/g, "") + .replace(/\uFEFF/g, "") + .replace(/\u00A0/g, " ") + .replace(/[ \t]+/g, " ") + .replace(/(\r?\n){3,}/g, "\n\n") + .trim(); + } + + function sanitizeTableText(text) { + return String(text || "") + .replace(/\u200B/g, "") + .replace(/\u200C/g, "") + .replace(/\u200D/g, "") + .replace(/\uFEFF/g, "") + .replace(/\u00A0/g, " ") + .replace(/[ ]+\t/g, "\t") + .replace(/\t[ ]+/g, "\t") + .replace(/(\r?\n){3,}/g, "\n\n") + .trim(); + } + + function getPlainText(node) { + if (!node) { + return ""; + } + + switch (node.nodeName.toUpperCase()) { + case "INPUT": + case "TEXTAREA": + return node.value || ""; + case "SELECT": + return Array.from(node.selectedOptions).map(function (option) { + return option.innerText; + }).join("\n"); + case "IMG": + return getImageText(node); + default: + return collectVisibleText(node); + } + } + + function getHtmlContent(target) { + if (!target) { + return ""; + } + + try { + if (target.range) { + const fragment = target.range.cloneContents(); + const wrapper = document.createElement("div"); + wrapper.appendChild(fragment); + return sanitizeHtml(serializeNodeChildrenToHtml(wrapper)); + } + + const node = target.node; + const element = targeting.getElementNode(node); + if (element && element.outerHTML) { + return sanitizeHtml(element.outerHTML); + } + } catch (error) { + // Fall back to plain text when DOM serialization is unavailable. + } + + return ""; + } + + function serializeNodeChildrenToHtml(node) { + return Array.from(node.childNodes).map(serializeNodeToHtml).join(""); + } + + function serializeNodeToHtml(node) { + if (node.nodeType == Node.TEXT_NODE) { + return escapeHtmlText(node.textContent || ""); + } + + if (node.nodeType == Node.ELEMENT_NODE && node.outerHTML) { + return node.outerHTML; + } + + return new XMLSerializer().serializeToString(node); + } + + function escapeHtmlText(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">"); + } + + function sanitizeHtml(html) { + if (!html) { + return ""; + } + + return html + .replace(//gi, "") + .replace(/\son\w+\s*=\s*["'][^"']*["']/gi, "") + .replace(/\son\w+\s*=\s*\S+/gi, "") + .replace(/javascript\s*:/gi, "") + .replace(/\sdata-track[\w-]*\s*=\s*["'][^"']*["']/gi, "") + .replace(/\sdata-analytics[\w-]*\s*=\s*["'][^"']*["']/gi, ""); + } + + return { + getText, + resolveExtractionContext, + getExtractionNode, + getImageText, + collectVisibleText, + extractTableAsTsv, + extractCodeText, + extractListAsText, + sanitizeText, + sanitizeTableText, + getPlainText, + getHtmlContent, + serializeNodeChildrenToHtml, + serializeNodeToHtml, + escapeHtmlText, + sanitizeHtml, + }; +} + +module.exports = { + createContentExtraction, +}; diff --git a/src/content/helpers.js b/src/content/helpers.js index 7af9f83..d9d3343 100644 --- a/src/content/helpers.js +++ b/src/content/helpers.js @@ -1,1290 +1,29 @@ -function createContentHelpers(context) { - const utils = context.utils; - const hoverState = context.state.hoverState; - - const SCOPE_WORD = 0; - const SCOPE_SENTENCE = 1; - const SCOPE_PARAGRAPH = 2; - const SCOPE_CONTAINER = 3; - - function settings() { - return context.state.settings; - } - - function pierceShadowDOM(element, x, y) { - if (!element) { - return element; - } - const root = element.shadowRoot; - if (!root) { - return element; - } - const deeper = root.elementFromPoint(x, y); - if (!deeper || deeper === element) { - return element; - } - return pierceShadowDOM(deeper, x, y); - } - - function syncPointerState(event) { - hoverState.pointerPageX = event.pageX; - hoverState.pointerPageY = event.pageY; - hoverState.pointerClientX = event.clientX; - hoverState.pointerClientY = event.clientY; - } - - function resolveScopedTarget(sourceNode, clientX, clientY, level) { - if (level <= SCOPE_WORD) { - return null; - } - - const caretRange = getCaretRangeAtPoint(clientX, clientY); - if (!caretRange) { - return null; - } - - const textNode = caretRange.startContainer; - if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { - return null; - } - - const fullText = textNode.textContent || ""; - - if (level === SCOPE_SENTENCE) { - const sentenceRange = expandToSentence(textNode, caretRange.startOffset); - if (sentenceRange) { - return { - kind: "scope", - node: textNode, - range: sentenceRange, - rect: getRangeBoundingRect(sentenceRange), - text: sentenceRange.toString().trim(), - }; - } - } - - if (level === SCOPE_PARAGRAPH) { - let paragraphElement = getElementNode(textNode); - while (paragraphElement && paragraphElement !== document.body) { - const display = window.getComputedStyle ? window.getComputedStyle(paragraphElement).display : ""; - if (display === "block" || display === "list-item" || display === "flex" || paragraphElement.nodeName === "P" || paragraphElement.nodeName === "LI") { - break; - } - paragraphElement = paragraphElement.parentElement; - } - if (paragraphElement && paragraphElement !== document.body) { - return createElementTarget(paragraphElement); - } - } - - if (level === SCOPE_CONTAINER) { - const container = getElementNode(textNode); - if (container && container.parentElement && container.parentElement !== document.body && container.parentElement !== document.documentElement) { - return createElementTarget(container.parentElement); - } - } - - return null; - } - - function expandToSentence(textNode, offset) { - const text = textNode.textContent || ""; - if (!text.trim()) { - return null; - } - - const sentenceBreaks = /[.!?。!?]+[\s]*/g; - const sentences = []; - let lastEnd = 0; - let match; - while ((match = sentenceBreaks.exec(text)) !== null) { - sentences.push({ start: lastEnd, end: match.index + match[0].length }); - lastEnd = match.index + match[0].length; - } - if (lastEnd < text.length) { - sentences.push({ start: lastEnd, end: text.length }); - } - - if (sentences.length === 0) { - sentences.push({ start: 0, end: text.length }); - } - - let target = sentences[0]; - for (let index = 0; index < sentences.length; index += 1) { - if (offset >= sentences[index].start && offset <= sentences[index].end) { - target = sentences[index]; - break; - } - } - - const range = document.createRange(); - range.setStart(textNode, target.start); - range.setEnd(textNode, Math.min(target.end, text.length)); - return range; - } - - function resolvePrecisionTarget(sourceNode, options) { - const localContext = options || {}; - const sourceElement = getElementNode(sourceNode); - const clientX = Number.isFinite(localContext.clientX) ? localContext.clientX : hoverState.pointerClientX; - const clientY = Number.isFinite(localContext.clientY) ? localContext.clientY : hoverState.pointerClientY; - - if (localContext.preferSelection !== false) { - const selectionTarget = getSelectionTarget(sourceElement, clientX, clientY); - if (selectionTarget) { - return selectionTarget; - } - } - - const scopeClientX = hoverState.scopeAnchorClientX !== null ? hoverState.scopeAnchorClientX : clientX; - const scopeClientY = hoverState.scopeAnchorClientY !== null ? hoverState.scopeAnchorClientY : clientY; - if (hoverState.scopeLevel > SCOPE_WORD) { - const scopedTarget = resolveScopedTarget(sourceElement, scopeClientX, scopeClientY, hoverState.scopeLevel); - if (scopedTarget) { - return scopedTarget; - } - } - - const deepTextTarget = getDeepTextTarget(clientX, clientY); - const fallbackElement = getDeepElementTarget(sourceElement, clientX, clientY); - const preliminaryTarget = deepTextTarget || createElementTarget(fallbackElement); - if (!preliminaryTarget) { - return null; - } - - const extractionContext = resolveExtractionContext(preliminaryTarget); - if (!extractionContext) { - return preliminaryTarget; - } - - switch (extractionContext.kind) { - case "selection": - case "scope": - return extractionContext; - case "table": - return { kind: "table", table: extractionContext.table, rect: extractionContext.table.getBoundingClientRect(), node: extractionContext.table }; - case "code": - return { kind: "code", container: extractionContext.container, rect: extractionContext.container.getBoundingClientRect(), node: extractionContext.container }; - case "list": - return { kind: "list", container: extractionContext.container, rect: extractionContext.container.getBoundingClientRect(), node: extractionContext.container }; - case "link": - return { kind: "link", anchor: extractionContext.anchor, rect: extractionContext.anchor.getBoundingClientRect(), node: extractionContext.anchor }; - case "image": - return { kind: "image", element: extractionContext.element, rect: extractionContext.element.getBoundingClientRect(), node: extractionContext.element }; - case "control": - return { kind: "control", element: extractionContext.element, rect: extractionContext.element.getBoundingClientRect(), node: extractionContext.element }; - case "text": { - let rect = null; - if (extractionContext.node && extractionContext.node.nodeType === Node.TEXT_NODE) { - const textRange = document.createRange(); - textRange.selectNodeContents(extractionContext.node); - rect = getRangeBoundingRect(textRange); - } else if (extractionContext.node && extractionContext.node.getBoundingClientRect) { - rect = extractionContext.node.getBoundingClientRect(); - } - return { kind: "text", node: extractionContext.node, rect: rect || preliminaryTarget.rect }; - } - default: - return preliminaryTarget; - } - } - - function getSelectionTarget(sourceElement, clientX, clientY) { - const selection = window.getSelection ? window.getSelection() : null; - if (selection && selection.rangeCount && !selection.isCollapsed) { - const selectionText = String(selection.toString() || "").trim(); - if (selectionText) { - const range = selection.getRangeAt(0).cloneRange(); - const rect = getRangeBoundingRect(range); - const commonNode = selection.anchorNode || range.commonAncestorContainer; - if ((!Number.isFinite(clientX) || !Number.isFinite(clientY)) || isPointInsideRect(rect, clientX, clientY) || (sourceElement && commonNode && sourceElement.contains(getElementNode(commonNode)))) { - return { - kind: "selection", - range: range, - rect: rect, - text: selectionText, - node: commonNode, - }; - } - } - } - - const activeElement = document.activeElement; - if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { - const start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; - const end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; - if (end > start) { - const selectedText = String(activeElement.value || "").slice(start, end).trim(); - if (selectedText && (!sourceElement || sourceElement === activeElement || activeElement.contains(sourceElement))) { - return { - kind: "selection", - rect: activeElement.getBoundingClientRect(), - text: selectedText, - node: activeElement, - }; - } - } - } - - return null; - } - - function getDeepTextTarget(clientX, clientY) { - if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) { - return null; - } - - const range = getCaretRangeAtPoint(clientX, clientY); - if (!range) { - return null; - } - - const node = range.startContainer; - if (node && node.nodeType == Node.TEXT_NODE && String(node.textContent || "").trim() && isNodeVisible(node)) { - const textRange = document.createRange(); - textRange.selectNodeContents(node); - return { - kind: "text", - node: node, - rect: getRangeBoundingRect(textRange), - }; - } - - return null; - } - - function getDeepElementTarget(sourceElement, clientX, clientY) { - const element = Number.isFinite(clientX) && Number.isFinite(clientY) - ? pierceShadowDOM(document.elementFromPoint(clientX, clientY), clientX, clientY) - : sourceElement; - return getClosestMeaningfulElement(element || sourceElement); - } - - function getCaretRangeAtPoint(clientX, clientY) { - if (document.caretPositionFromPoint) { - const position = document.caretPositionFromPoint(clientX, clientY); - if (position && position.offsetNode) { - const range = document.createRange(); - range.setStart(position.offsetNode, position.offset); - range.setEnd(position.offsetNode, position.offset); - return range; - } - } - - if (document.caretRangeFromPoint) { - return document.caretRangeFromPoint(clientX, clientY); - } - - return null; - } - - function getClosestMeaningfulElement(element) { - let current = getElementNode(element); - while (current) { - if (hasMeaningfulText(current) || current.nodeName.toUpperCase() == "IMG" || current.nodeName.toUpperCase() == "INPUT" || current.nodeName.toUpperCase() == "TEXTAREA" || current.nodeName.toUpperCase() == "SELECT") { - return current; - } - current = current.parentElement; - } - return getElementNode(element); - } - - function createElementTarget(element) { - if (!element) { - return null; - } - - return { - kind: "element", - node: element, - rect: element.getBoundingClientRect ? element.getBoundingClientRect() : null, - }; - } - - function hasMeaningfulText(node) { - if (!node) { - return false; - } - if (node.nodeType == Node.TEXT_NODE) { - return String(node.textContent || "").trim().length > 0; - } - if (node.nodeType != Node.ELEMENT_NODE) { - return false; - } - if (node.nodeName.toUpperCase() == "IMG") { - return !!getImageText(node); - } - return collectVisibleText(node).length > 0; - } - - function isNodeVisible(node) { - const element = getElementNode(node); - if (!element) { - return false; - } - if (element.hidden || element.getAttribute("aria-hidden") == "true") { - return false; - } - const style = window.getComputedStyle ? window.getComputedStyle(element) : null; - if (style && (style.display == "none" || style.visibility == "hidden")) { - return false; - } - return true; - } - - function getRangeBoundingRect(range) { - if (!range) { - return null; - } - - const rects = Array.from(range.getClientRects ? range.getClientRects() : []).filter(function (rect) { - return rect.width > 0 && rect.height > 0; - }); - if (!rects.length) { - const fallback = range.getBoundingClientRect ? range.getBoundingClientRect() : null; - return fallback && fallback.width > 0 && fallback.height > 0 ? fallback : null; - } - - return rects.reduce(function (acc, current) { - if (!acc) { - return { - left: current.left, - top: current.top, - right: current.right, - bottom: current.bottom, - width: current.width, - height: current.height, - }; - } - - const left = Math.min(acc.left, current.left); - const top = Math.min(acc.top, current.top); - const right = Math.max(acc.right, current.right); - const bottom = Math.max(acc.bottom, current.bottom); - return { - left: left, - top: top, - right: right, - bottom: bottom, - width: right - left, - height: bottom - top, - }; - }, null); - } - - function isPointInsideRect(rect, clientX, clientY) { - if (!rect || !Number.isFinite(clientX) || !Number.isFinite(clientY)) { - return false; - } - return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom; - } - - function shouldIgnoreElement(node) { - return settings().avoidEditable && utils.isEditableSurface(getElementNode(node)); - } - - function shouldShowPreview() { - return settings().previewEnabled - && hoverState.previewModifierActive - && !context.isCurrentHostExcluded() - && !!hoverState.hoveredElement - && hoverState.hoveredElement.isConnected - && !shouldIgnoreElement(hoverState.hoveredElement); - } - - function schedulePreviewUpdate() { - if (!shouldShowPreview()) { - hidePreview(); - return; - } - - if (hoverState.previewAnimationFrame) { - return; - } - - hoverState.previewAnimationFrame = window.requestAnimationFrame(function () { - hoverState.previewAnimationFrame = 0; - renderPreview(); - }); - } - - function renderPreview() { - if (!shouldShowPreview()) { - hidePreview(); - return; - } - - const precisionTarget = resolvePrecisionTarget(hoverState.hoveredElement, { - clientX: hoverState.pointerClientX, - clientY: hoverState.pointerClientY, - preferSelection: true, - }); - if (!precisionTarget || !precisionTarget.rect) { - hidePreview(); - return; - } - - const rect = precisionTarget.rect; - if (!hasRenderableRect(rect)) { - hidePreview(); - return; - } - - hoverState.lastRenderedTarget = precisionTarget; - - const overlayState = getOverlayState(); - positionOverlayBox(overlayState.preview, rect, 2); - overlayState.preview.classList.add("visible"); - - const scopeLabels = ["", "Sentence", "Paragraph", "Container"]; - if (hoverState.scopeLevel > 0 && hoverState.scopeLevel < scopeLabels.length) { - overlayState.scopeBadge.textContent = scopeLabels[hoverState.scopeLevel]; - overlayState.scopeBadge.style.display = "block"; - const previewTop = parseFloat(overlayState.preview.style.top) || 0; - const previewLeft = parseFloat(overlayState.preview.style.left) || 0; - overlayState.scopeBadge.style.top = (previewTop - 22) + "px"; - overlayState.scopeBadge.style.left = previewLeft + "px"; - } else { - overlayState.scopeBadge.style.display = "none"; - } - - const textLength = getText(precisionTarget).length; - if (textLength > 3000) { - overlayState.warnBadge.textContent = "⚠ " + Math.round(textLength / 1000) + "k chars"; - overlayState.warnBadge.style.display = "block"; - const previewTop = parseFloat(overlayState.preview.style.top) || 0; - const previewLeft = parseFloat(overlayState.preview.style.left) || 0; - const previewWidth = parseFloat(overlayState.preview.style.width) || 0; - overlayState.warnBadge.style.top = (previewTop - 22) + "px"; - overlayState.warnBadge.style.left = (previewLeft + previewWidth - 80) + "px"; - } else { - overlayState.warnBadge.style.display = "none"; - } - } - - function hidePreview() { - if (hoverState.previewAnimationFrame) { - window.cancelAnimationFrame(hoverState.previewAnimationFrame); - hoverState.previewAnimationFrame = 0; - } - - if (!context.state.overlayState) { - hoverState.lastRenderedTarget = null; - return; - } - - hoverState.lastRenderedTarget = null; - context.state.overlayState.preview.classList.remove("visible"); - context.state.overlayState.scopeBadge.style.display = "none"; - context.state.overlayState.warnBadge.style.display = "none"; - } - - function getText(target) { - const extractionContext = resolveExtractionContext(target); - if (!extractionContext) { - return ""; - } - - let raw; - switch (extractionContext.kind) { - case "selection": - raw = extractionContext.text; - break; - case "scope": - raw = extractionContext.text || collectVisibleText(extractionContext.node).trim(); - break; - case "table": - raw = extractTableAsTsv(extractionContext.table); - break; - case "code": - raw = extractCodeText(extractionContext.container); - break; - case "list": - raw = extractListAsText(extractionContext.container); - break; - case "image": - raw = getImageText(extractionContext.element); - break; - case "link": - raw = "[" + (collectVisibleText(extractionContext.anchor).trim() || extractionContext.anchor.href) + "](" + extractionContext.anchor.href + ")"; - break; - case "control": - raw = getPlainText(extractionContext.element).trim(); - break; - default: - raw = collectVisibleText(extractionContext.node).trim(); - } - - return sanitizeText(raw); - } - - function resolveExtractionContext(target) { - if (!target) { - return null; - } - - if (target.kind == "selection" || target.kind == "scope") { - return target; - } - - const node = target.node || target.element || getElementNode(target); - const element = getElementNode(node); - if (!element) { - return null; - } - - if (element.nodeName.toUpperCase() == "IMG") { - return { kind: "image", element: element }; - } - - const table = typeof element.closest == "function" ? element.closest("table") : null; - if (table) { - return { kind: "table", table: table }; - } - - const codeContainer = typeof element.closest == "function" ? element.closest("pre, code") : null; - if (codeContainer) { - return { kind: "code", container: codeContainer }; - } - - const listContainer = typeof element.closest == "function" ? element.closest("ul, ol") : null; - if (listContainer) { - return { kind: "list", container: listContainer }; - } - - const anchor = typeof element.closest == "function" ? element.closest("a[href]") : null; - if (anchor) { - return { kind: "link", anchor: anchor }; - } - - const tagName = element.nodeName.toUpperCase(); - if (tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "SELECT") { - return { kind: "control", element: element }; - } - - return { kind: "text", node: node }; - } - - function getExtractionNode(node) { - const element = getElementNode(node); - if (!element) { - return null; - } - return element; - } - - function getElementNode(node) { - if (!node) { - return null; - } - - if (node.nodeType == Node.TEXT_NODE) { - return node.parentNode; - } - - return node.nodeType == Node.ELEMENT_NODE ? node : null; - } - - function getImageText(node) { - return node.getAttribute("src") || node.getAttribute("alt") || ""; - } - - function collectVisibleText(node) { - if (!node) { - return ""; - } - - if (node.nodeType == Node.TEXT_NODE) { - return isNodeVisible(node) ? String(node.textContent || "") : ""; - } - - const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { - acceptNode: function (textNode) { - return isNodeVisible(textNode) && String(textNode.textContent || "").trim() - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT; - } - }); - - const parts = []; - let current = walker.nextNode(); - while (current) { - parts.push(String(current.textContent || "").trim()); - current = walker.nextNode(); - } +const { createContentTargeting } = require("./targeting.js"); +const { createContentExtraction } = require("./extraction.js"); +const { createContentOverlay } = require("./overlay.js"); +const { createContentPersistence } = require("./persistence.js"); +const { createContentClipboard } = require("./clipboard.js"); - return parts.join(" ").replace(/\s+/g, " ").trim(); - } - - function extractTableAsTsv(table) { - return Array.from(table.rows || []).map(function (row) { - return Array.from(row.cells || []).filter(isNodeVisible).map(function (cell) { - return collectVisibleText(cell).replace(/\s*\n+\s*/g, " ").trim(); - }).join("\t"); - }).filter(Boolean).join("\n"); - } - - function extractCodeText(container) { - const clone = container.cloneNode(true); - Array.from(clone.querySelectorAll("[aria-hidden='true'], .line-numbers, .line-number, .lineno, .gutter, .blob-num")).forEach(function (element) { - element.remove(); - }); - return collectVisibleText(clone).replace(/\u00a0/g, " ").trim(); - } - - function extractListAsText(container) { - const isOrdered = container.nodeName.toUpperCase() === "OL"; - const items = Array.from(container.children).filter(function (child) { - return child.nodeName.toUpperCase() === "LI" && isNodeVisible(child); - }); - - return items.map(function (li, index) { - const nestedList = li.querySelector("ul, ol"); - let mainText = ""; - - if (nestedList) { - const clone = li.cloneNode(true); - Array.from(clone.querySelectorAll("ul, ol")).forEach(function (nested) { - nested.remove(); - }); - mainText = collectVisibleText(clone).trim(); - } else { - mainText = collectVisibleText(li).trim(); - } - - const prefix = isOrdered ? (index + 1) + ". " : "- "; - let line = prefix + mainText; - - if (nestedList) { - const nestedLines = extractListAsText(nestedList).split("\n").map(function (nestedLine) { - return " " + nestedLine; - }).join("\n"); - line += "\n" + nestedLines; - } - - return line; - }).join("\n"); - } - - function sanitizeText(text) { - return String(text || "") - .replace(/\u200B/g, "") - .replace(/\u200C/g, "") - .replace(/\u200D/g, "") - .replace(/\uFEFF/g, "") - .replace(/\u00A0/g, " ") - .replace(/[ \t]+/g, " ") - .replace(/(\r?\n){3,}/g, "\n\n") - .trim(); - } - - function getPlainText(node) { - if (!node) { - return ""; - } - - switch (node.nodeName.toUpperCase()) { - case "INPUT": - case "TEXTAREA": - return node.value || ""; - case "SELECT": - return Array.from(node.selectedOptions).map(function (option) { - return option.innerText; - }).join("\n"); - case "IMG": - return getImageText(node); - default: - return collectVisibleText(node); - } - } - - async function copyCommand(clickedElement, source, options) { - const precisionTarget = resolvePrecisionTarget(clickedElement, options); - if (!precisionTarget) { - return; - } - await executePrecisionCopy(precisionTarget, source); - } - - async function executePrecisionCopy(precisionTarget, source) { - const text = getText(precisionTarget); - if (!text) { - return; - } - - const htmlContent = getHtmlContent(precisionTarget); - const result = "copied"; - await copy(text, htmlContent); - - hoverState.scopeLevel = 0; - hoverState.scopeAnchorClientX = null; - hoverState.scopeAnchorClientY = null; - - showCopyFeedback(precisionTarget.rect, result); - const analyticsEvents = [{ - type: getAnalyticsTypeForResult(result, source || "click", precisionTarget.kind == "selection"), - hostname: window.location.hostname, - toastKind: getToastAnalyticsKind(result), - }]; - if ((source || "click") == "shortcut") { - analyticsEvents.push({ - type: "shortcut", - hostname: window.location.hostname, - }); - } - saveAnalyticsEvents(analyticsEvents); - await saveHistory(text, result, source || "click", precisionTarget.kind == "selection"); - } - - function getHtmlContent(target) { - if (!target) { - return ""; - } - - try { - if (target.range) { - const fragment = target.range.cloneContents(); - const wrapper = document.createElement("div"); - wrapper.appendChild(fragment); - return sanitizeHtml(serializeNodeChildrenToHtml(wrapper)); - } - - const node = target.node; - const element = getElementNode(node); - if (element && element.outerHTML) { - return sanitizeHtml(element.outerHTML); - } - } catch (error) { - // Silently fall back to no HTML - } - - return ""; - } - - function serializeNodeChildrenToHtml(node) { - return Array.from(node.childNodes).map(serializeNodeToHtml).join(""); - } - - function serializeNodeToHtml(node) { - if (node.nodeType == Node.TEXT_NODE) { - return escapeHtmlText(node.textContent || ""); - } - - if (node.nodeType == Node.ELEMENT_NODE && node.outerHTML) { - return node.outerHTML; - } - - return new XMLSerializer().serializeToString(node); - } - - function escapeHtmlText(text) { - return String(text) - .replace(/&/g, "&") - .replace(//g, ">"); - } - - function sanitizeHtml(html) { - if (!html) { - return ""; - } - - return html - .replace(//gi, "") - .replace(/\son\w+\s*=\s*["'][^"']*["']/gi, "") - .replace(/\son\w+\s*=\s*\S+/gi, "") - .replace(/javascript\s*:/gi, "") - .replace(/\sdata-track[\w-]*\s*=\s*["'][^"']*["']/gi, "") - .replace(/\sdata-analytics[\w-]*\s*=\s*["'][^"']*["']/gi, ""); - } - - async function copy(text, htmlContent) { - if (htmlContent && navigator.clipboard && typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined") { - try { - const textBlob = new Blob([text], { type: "text/plain" }); - const htmlBlob = new Blob([htmlContent], { type: "text/html" }); - await navigator.clipboard.write([ - new ClipboardItem({ - "text/plain": textBlob, - "text/html": htmlBlob - }) - ]); - return; - } catch (error) { - console.warn("ClipboardItem write failed, falling back to writeText.", error); - } - } - - if (navigator.clipboard && typeof navigator.clipboard.writeText == "function") { - try { - await navigator.clipboard.writeText(text); - return; - } catch (error) { - console.warn("Clipboard writeText failed, using fallback copy.", error); - } - } - - const container = document.body || document.documentElement; - const textArea = document.createElement("textarea"); - textArea.style.cssText = "position:absolute;left:-100%;top:0;"; - - try { - container.appendChild(textArea); - textArea.value = text; - textArea.select(); - context.state.suppressNativeCopyTracking = true; - if (!document.execCommand("copy")) { - console.error("Copy failed."); - } - } finally { - context.state.suppressNativeCopyTracking = false; - textArea.remove(); - } - } - - function showCopyFeedback(rect, result) { - const overlayState = getOverlayState(); - - if (hasRenderableRect(rect)) { - positionOverlayBox(overlayState.feedback, rect, 4); - overlayState.feedback.style.animationDuration = Math.max(350, settings().toastDurationMs) + "ms"; - restartAnimation(overlayState.feedback, "visible"); - } - - spawnCursorToast(getCopyToastText(result), getToastPageX(rect), getToastPageY(rect)); - } - - function getCopyToastText(result) { - return context.t("toast_copied", "Copied!"); - } - - function getToastPageX(rect) { - if (hoverState.pointerPageX !== null) { - return hoverState.pointerPageX; - } - - return rect.left + window.scrollX + (rect.width / 2); - } - - function getToastPageY(rect) { - if (hoverState.pointerPageY !== null) { - return hoverState.pointerPageY; - } - - return rect.top + window.scrollY + Math.min(24, rect.height / 2); - } - - function spawnCursorToast(label, pageX, pageY) { - const overlayState = getOverlayState(); - const toast = document.createElement("div"); - toast.className = "cursor-toast"; - toast.textContent = label; - - const toastW = 80; - const toastH = 28; - const margin = 8; - const clientX = pageX - window.scrollX; - const clientY = pageY - window.scrollY; - - let adjustedClientX = clientX + 16; - if (adjustedClientX + toastW + margin > window.innerWidth) { - adjustedClientX = Math.max(margin, clientX - toastW - 16); - } - - let adjustedClientY = clientY - 18; - if (adjustedClientY - toastH < margin) { - adjustedClientY = Math.min(window.innerHeight - toastH - margin, clientY + 24); - } - - const finalPageX = adjustedClientX + window.scrollX; - const finalPageY = adjustedClientY + window.scrollY; - - toast.style.left = Math.round(finalPageX) + "px"; - toast.style.top = Math.round(finalPageY) + "px"; - toast.style.animationDuration = settings().toastDurationMs + "ms"; - overlayState.layer.appendChild(toast); - toast.addEventListener("animationend", function () { - toast.remove(); - }, { once: true }); - } - - function showStatusToast(label) { - const overlayState = getOverlayState(); - const toast = document.createElement("div"); - toast.className = "cursor-toast"; - toast.textContent = label; - toast.style.left = Math.round(window.scrollX + (window.innerWidth / 2)) + "px"; - toast.style.top = Math.round(window.scrollY + Math.min(window.innerHeight * 0.3, 180)) + "px"; - toast.style.transform = "translateX(-50%)"; - toast.style.animationDuration = settings().toastDurationMs + "ms"; - overlayState.layer.appendChild(toast); - toast.addEventListener("animationend", function () { - toast.remove(); - }, { once: true }); - } - - async function saveHistory(text, result, source, isSelectionBased) { - if (!settings().copyHistoryLimit) { - return; - } - - try { - const current = await utils.safeStorageGet("local", { copyHistory: [] }); - const nextHistory = utils.pushHistoryEntry(current.copyHistory, { - text: text, - snippet: utils.getTextSnippet(text), - source: source, - mode: "copy", - url: window.location.href, - hostname: window.location.hostname, - pinned: false, - replayCount: 0, - lastReplayedAt: isSelectionBased ? Date.now() : 0, - }, settings().copyHistoryLimit); - - await utils.safeStorageSet("local", { copyHistory: nextHistory }); - } catch (error) { - if (context.handleExtensionContextError(error)) { - return; - } - - console.warn("Saving copy history failed.", error); - } - } - - async function saveAnalyticsEvent(event) { - return saveAnalyticsEvents([event]); - } - - async function saveAnalyticsEvents(events) { - try { - const current = await utils.safeStorageGet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); - let nextAnalytics = current.copyAnalytics; - (Array.isArray(events) ? events : [events]).forEach(function (event) { - nextAnalytics = utils.recordAnalyticsEvent(nextAnalytics, event || {}); - }); - await utils.safeStorageSet("local", { copyAnalytics: nextAnalytics }); - } catch (error) { - if (context.handleExtensionContextError(error)) { - return; - } - - console.warn("Saving copy analytics failed.", error); - } - } - - function getAnalyticsTypeForResult(result, source, isSelectionBased) { - if (source == "history") { - return "historyReplayCopy"; - } - - if (isSelectionBased) { - return "selectionCopy"; - } - - return "copy"; - } - - function getToastAnalyticsKind(result) { - return "copied"; - } - - function resolveShortcutTarget() { - if (hoverState.hoveredElement && hoverState.hoveredElement.isConnected) { - return hoverState.hoveredElement; - } - - if (document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement) { - return document.activeElement; - } - - const selection = window.getSelection ? window.getSelection() : null; - if (selection && selection.anchorNode) { - return selection.anchorNode; - } - - return null; - } - - function getNativeCopiedText() { - const activeElement = document.activeElement; - if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { - const start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; - const end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; - const value = String(activeElement.value || ""); - if (end > start) { - return value.slice(start, end).trim(); - } - } - - const selection = window.getSelection ? window.getSelection() : null; - return selection ? String(selection.toString() || "").trim() : ""; - } - - function positionOverlayBox(element, rect, expansion) { - const pageTop = rect.top + window.scrollY - expansion; - const pageLeft = rect.left + window.scrollX - expansion; - const width = rect.width + (expansion * 2); - const height = rect.height + (expansion * 2); - - element.style.top = Math.round(pageTop) + "px"; - element.style.left = Math.round(pageLeft) + "px"; - element.style.width = Math.max(1, Math.round(width)) + "px"; - element.style.height = Math.max(1, Math.round(height)) + "px"; - } - - function hasRenderableRect(rect) { - return !!rect && rect.width > 0 && rect.height > 0; - } - - function restartAnimation(element, className) { - element.classList.remove(className); - void element.offsetWidth; - element.classList.add(className); - } - - function getOverlayState() { - if (context.state.overlayState && context.state.overlayState.host.isConnected) { - return context.state.overlayState; - } - - const host = document.createElement("div"); - host.setAttribute("data-copy-text-overlay-root", ""); - Object.assign(host.style, { - all: "initial", - position: "absolute", - top: "0", - left: "0", - width: "0", - height: "0", - zIndex: "2147483647", - pointerEvents: "none", - }); - - const shadowRoot = host.attachShadow({ mode: "closed" }); - const style = document.createElement("style"); - style.textContent = [ - ":host { all: initial; }", - ".layer {", - " position: relative;", - " pointer-events: none;", - "}", - ".preview,", - ".feedback,", - ".cursor-toast,", - ".scope-badge,", - ".warn-badge {", - " position: absolute;", - " pointer-events: none;", - " box-sizing: border-box;", - "}", - ".preview {", - " z-index: 99998;", - " opacity: 0;", - " border: 2px solid rgba(250, 204, 21, 0.7);", - " border-radius: 6px;", - " background: rgba(250, 204, 21, 0.18);", - " mix-blend-mode: multiply;", - " box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.08), inset 0 0 12px rgba(250, 204, 21, 0.12);", - " transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1), top 100ms ease, left 100ms ease, width 100ms ease, height 100ms ease;", - "}", - ".preview.visible {", - " opacity: 1;", - "}", - "@media (prefers-color-scheme: dark) {", - " .preview {", - " border-color: rgba(56, 189, 248, 0.6);", - " background: rgba(56, 189, 248, 0.12);", - " mix-blend-mode: screen;", - " box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.1), inset 0 0 12px rgba(56, 189, 248, 0.08);", - " }", - "}", - ".scope-badge {", - " z-index: 100001;", - " display: none;", - " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", - " font-size: 10px;", - " font-weight: 700;", - " line-height: 1;", - " letter-spacing: 0.04em;", - " text-transform: uppercase;", - " color: #92400e;", - " background: rgba(253, 230, 138, 0.92);", - " border: 1px solid rgba(250, 204, 21, 0.4);", - " padding: 3px 7px;", - " border-radius: 6px;", - " white-space: nowrap;", - "}", - "@media (prefers-color-scheme: dark) {", - " .scope-badge {", - " color: #bae6fd;", - " background: rgba(7, 89, 133, 0.88);", - " border-color: rgba(56, 189, 248, 0.35);", - " }", - "}", - ".warn-badge {", - " z-index: 100001;", - " display: none;", - " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", - " font-size: 10px;", - " font-weight: 700;", - " line-height: 1;", - " color: #b91c1c;", - " background: rgba(254, 226, 226, 0.94);", - " border: 1px solid rgba(248, 113, 113, 0.4);", - " padding: 3px 7px;", - " border-radius: 6px;", - " white-space: nowrap;", - "}", - "@media (prefers-color-scheme: dark) {", - " .warn-badge {", - " color: #fca5a5;", - " background: rgba(127, 29, 29, 0.88);", - " border-color: rgba(248, 113, 113, 0.35);", - " }", - "}", - ".feedback {", - " z-index: 99999;", - " opacity: 0;", - " border: 1px solid rgba(110, 231, 183, 0.96);", - " border-radius: 8px;", - " background: linear-gradient(135deg, rgba(45, 212, 191, 0.22), rgba(34, 197, 94, 0.18));", - " box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.14), 0 0 28px rgba(45, 212, 191, 0.35);", - "}", - ".feedback.visible {", - " animation: feedbackPulse 1150ms cubic-bezier(0.16, 1, 0.3, 1) forwards;", - "}", - "@keyframes feedbackPulse {", - " 0% { opacity: 0.96; transform: scale(0.985); }", - " 58% { opacity: 0.7; transform: scale(1); }", - " 100% { opacity: 0; transform: scale(1.02); }", - "}", - ".cursor-toast {", - " z-index: 100000;", - " font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;", - " font-size: 12px;", - " font-weight: 650;", - " line-height: 1;", - " letter-spacing: 0.01em;", - " color: #ecfeff;", - " white-space: nowrap;", - " padding: 7px 10px;", - " border-radius: 999px;", - " border: 1px solid rgba(125, 211, 252, 0.32);", - " background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(8, 47, 73, 0.92));", - " box-shadow: 0 10px 24px rgba(2, 6, 23, 0.28), 0 0 18px rgba(45, 212, 191, 0.24);", - " animation: cursorToastFloat 1250ms cubic-bezier(0.22, 1, 0.36, 1) forwards;", - "}", - "@keyframes cursorToastFloat {", - " 0% { opacity: 0; transform: translateY(4px); }", - " 12% { opacity: 1; transform: translateY(0); }", - " 100% { opacity: 0; transform: translateY(-28px); }", - "}", - "@media (prefers-reduced-motion: reduce) {", - " .preview { transition: none; }", - " .feedback.visible { animation-duration: 350ms; }", - " .cursor-toast { animation-duration: 700ms; }", - "}", - ].join("\n"); - - const layer = document.createElement("div"); - layer.className = "layer"; - - const preview = document.createElement("div"); - preview.className = "preview"; - - const feedback = document.createElement("div"); - feedback.className = "feedback"; - - const scopeBadge = document.createElement("div"); - scopeBadge.className = "scope-badge"; - - const warnBadge = document.createElement("div"); - warnBadge.className = "warn-badge"; - - layer.appendChild(preview); - layer.appendChild(feedback); - layer.appendChild(scopeBadge); - layer.appendChild(warnBadge); - shadowRoot.appendChild(style); - shadowRoot.appendChild(layer); - - (document.documentElement || document.body).appendChild(host); - - context.state.overlayState = { - host: host, - layer: layer, - preview: preview, - feedback: feedback, - scopeBadge: scopeBadge, - warnBadge: warnBadge, - }; - - return context.state.overlayState; - } - - return { - SCOPE_WORD, - SCOPE_SENTENCE, - SCOPE_PARAGRAPH, - SCOPE_CONTAINER, - pierceShadowDOM, - syncPointerState, - resolveScopedTarget, - expandToSentence, - resolvePrecisionTarget, - getSelectionTarget, - getDeepTextTarget, - getDeepElementTarget, - getCaretRangeAtPoint, - getClosestMeaningfulElement, - createElementTarget, - hasMeaningfulText, - isNodeVisible, - getRangeBoundingRect, - isPointInsideRect, - shouldIgnoreElement, - shouldShowPreview, - schedulePreviewUpdate, - renderPreview, - hidePreview, - getText, - resolveExtractionContext, - getExtractionNode, - getElementNode, - getImageText, - collectVisibleText, - extractTableAsTsv, - extractCodeText, - extractListAsText, - sanitizeText, - getPlainText, - copyCommand, - executePrecisionCopy, - getHtmlContent, - serializeNodeChildrenToHtml, - serializeNodeToHtml, - escapeHtmlText, - sanitizeHtml, - copy, - showCopyFeedback, - getCopyToastText, - getToastPageX, - getToastPageY, - spawnCursorToast, - showStatusToast, - saveHistory, - saveAnalyticsEvent, - saveAnalyticsEvents, - getAnalyticsTypeForResult, - getToastAnalyticsKind, - resolveShortcutTarget, - getNativeCopiedText, - positionOverlayBox, - hasRenderableRect, - restartAnimation, - getOverlayState, - }; +function createContentHelpers(context) { + let extraction; + + const targeting = createContentTargeting(context, { + getExtraction: function () { + return extraction; + }, + }); + extraction = createContentExtraction(context, targeting); + const overlay = createContentOverlay(context, targeting, extraction); + const persistence = createContentPersistence(context); + const clipboard = createContentClipboard(context, targeting, extraction, overlay, persistence); + + return Object.assign({}, + targeting, + extraction, + overlay, + persistence, + clipboard + ); } module.exports = { diff --git a/src/content/overlay.js b/src/content/overlay.js new file mode 100644 index 0000000..af8a582 --- /dev/null +++ b/src/content/overlay.js @@ -0,0 +1,302 @@ +function createContentOverlay(context, targeting, extraction) { + const hoverState = context.state.hoverState; + + function settings() { + return context.state.settings; + } + + function shouldShowPreview() { + return settings().previewEnabled + && hoverState.previewModifierActive + && !context.isCurrentHostExcluded() + && !!hoverState.hoveredElement + && hoverState.hoveredElement.isConnected + && !targeting.shouldIgnoreElement(hoverState.hoveredElement); + } + + function schedulePreviewUpdate() { + if (!shouldShowPreview()) { + hidePreview(); + return; + } + + if (hoverState.previewAnimationFrame) { + return; + } + + hoverState.previewAnimationFrame = window.requestAnimationFrame(function () { + hoverState.previewAnimationFrame = 0; + renderPreview(); + }); + } + + function renderPreview() { + if (!shouldShowPreview()) { + hidePreview(); + return; + } + + const precisionTarget = targeting.resolvePrecisionTarget(hoverState.hoveredElement, { + clientX: hoverState.pointerClientX, + clientY: hoverState.pointerClientY, + preferSelection: true, + }); + if (!precisionTarget || !precisionTarget.rect) { + hidePreview(); + return; + } + + const rect = precisionTarget.rect; + if (!hasRenderableRect(rect)) { + hidePreview(); + return; + } + + hoverState.lastRenderedTarget = precisionTarget; + + const overlayState = getOverlayState(); + positionOverlayBox(overlayState.preview, rect, 2); + overlayState.preview.classList.add("visible"); + + const scopeLabels = ["", "Sentence", "Paragraph", "Container"]; + if (hoverState.scopeLevel > 0 && hoverState.scopeLevel < scopeLabels.length) { + overlayState.scopeBadge.textContent = scopeLabels[hoverState.scopeLevel]; + overlayState.scopeBadge.style.display = "block"; + const previewTop = parseFloat(overlayState.preview.style.top) || 0; + const previewLeft = parseFloat(overlayState.preview.style.left) || 0; + overlayState.scopeBadge.style.top = (previewTop - 22) + "px"; + overlayState.scopeBadge.style.left = previewLeft + "px"; + } else { + overlayState.scopeBadge.style.display = "none"; + } + + const textLength = extraction.getText(precisionTarget).length; + if (textLength > 3000) { + overlayState.warnBadge.textContent = "! " + Math.round(textLength / 1000) + "k chars"; + overlayState.warnBadge.style.display = "block"; + const previewTop = parseFloat(overlayState.preview.style.top) || 0; + const previewLeft = parseFloat(overlayState.preview.style.left) || 0; + const previewWidth = parseFloat(overlayState.preview.style.width) || 0; + overlayState.warnBadge.style.top = (previewTop - 22) + "px"; + overlayState.warnBadge.style.left = (previewLeft + previewWidth - 80) + "px"; + } else { + overlayState.warnBadge.style.display = "none"; + } + } + + function hidePreview() { + if (hoverState.previewAnimationFrame) { + window.cancelAnimationFrame(hoverState.previewAnimationFrame); + hoverState.previewAnimationFrame = 0; + } + + if (!context.state.overlayState) { + hoverState.lastRenderedTarget = null; + return; + } + + hoverState.lastRenderedTarget = null; + context.state.overlayState.preview.classList.remove("visible"); + context.state.overlayState.scopeBadge.style.display = "none"; + context.state.overlayState.warnBadge.style.display = "none"; + } + + function showCopyFeedback(rect, result) { + const overlayState = getOverlayState(); + + if (hasRenderableRect(rect)) { + positionOverlayBox(overlayState.feedback, rect, 4); + overlayState.feedback.style.animationDuration = Math.max(350, settings().toastDurationMs) + "ms"; + restartAnimation(overlayState.feedback, "visible"); + } + + spawnCursorToast(getCopyToastText(result), getToastPageX(rect), getToastPageY(rect)); + } + + function getCopyToastText(result) { + return context.t("toast_copied", "Copied!"); + } + + function getToastPageX(rect) { + if (hoverState.pointerPageX !== null) { + return hoverState.pointerPageX; + } + + return rect.left + window.scrollX + (rect.width / 2); + } + + function getToastPageY(rect) { + if (hoverState.pointerPageY !== null) { + return hoverState.pointerPageY; + } + + return rect.top + window.scrollY + Math.min(24, rect.height / 2); + } + + function spawnCursorToast(label, pageX, pageY) { + const overlayState = getOverlayState(); + const toast = document.createElement("div"); + toast.className = "cursor-toast"; + toast.textContent = label; + + const toastW = 80; + const toastH = 28; + const margin = 8; + const clientX = pageX - window.scrollX; + const clientY = pageY - window.scrollY; + + let adjustedClientX = clientX + 16; + if (adjustedClientX + toastW + margin > window.innerWidth) { + adjustedClientX = Math.max(margin, clientX - toastW - 16); + } + + let adjustedClientY = clientY - 18; + if (adjustedClientY - toastH < margin) { + adjustedClientY = Math.min(window.innerHeight - toastH - margin, clientY + 24); + } + + const finalPageX = adjustedClientX + window.scrollX; + const finalPageY = adjustedClientY + window.scrollY; + + toast.style.left = Math.round(finalPageX) + "px"; + toast.style.top = Math.round(finalPageY) + "px"; + toast.style.animationDuration = settings().toastDurationMs + "ms"; + overlayState.layer.appendChild(toast); + toast.addEventListener("animationend", function () { + toast.remove(); + }, { once: true }); + } + + function showStatusToast(label) { + const overlayState = getOverlayState(); + const toast = document.createElement("div"); + toast.className = "cursor-toast"; + toast.textContent = label; + toast.style.left = Math.round(window.scrollX + (window.innerWidth / 2)) + "px"; + toast.style.top = Math.round(window.scrollY + Math.min(window.innerHeight * 0.3, 180)) + "px"; + toast.style.transform = "translateX(-50%)"; + toast.style.animationDuration = settings().toastDurationMs + "ms"; + overlayState.layer.appendChild(toast); + toast.addEventListener("animationend", function () { + toast.remove(); + }, { once: true }); + } + + function positionOverlayBox(element, rect, expansion) { + const pageTop = rect.top + window.scrollY - expansion; + const pageLeft = rect.left + window.scrollX - expansion; + const width = rect.width + (expansion * 2); + const height = rect.height + (expansion * 2); + + element.style.top = Math.round(pageTop) + "px"; + element.style.left = Math.round(pageLeft) + "px"; + element.style.width = Math.max(1, Math.round(width)) + "px"; + element.style.height = Math.max(1, Math.round(height)) + "px"; + } + + function hasRenderableRect(rect) { + return !!rect && rect.width > 0 && rect.height > 0; + } + + function restartAnimation(element, className) { + element.classList.remove(className); + void element.offsetWidth; + element.classList.add(className); + } + + function getOverlayState() { + if (context.state.overlayState && context.state.overlayState.host.isConnected) { + return context.state.overlayState; + } + + const host = document.createElement("div"); + host.setAttribute("data-copy-text-overlay-root", ""); + Object.assign(host.style, { + all: "initial", + position: "absolute", + top: "0", + left: "0", + width: "0", + height: "0", + zIndex: "2147483647", + pointerEvents: "none", + }); + + const shadowRoot = host.attachShadow({ mode: "closed" }); + const style = document.createElement("style"); + style.textContent = [ + ":host { all: initial; }", + ".layer { position: relative; pointer-events: none; }", + ".preview, .feedback, .cursor-toast, .scope-badge, .warn-badge { position: absolute; pointer-events: none; box-sizing: border-box; }", + ".preview { z-index: 99998; opacity: 0; border: 2px solid rgba(250, 204, 21, 0.7); border-radius: 6px; background: rgba(250, 204, 21, 0.18); mix-blend-mode: multiply; box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.08), inset 0 0 12px rgba(250, 204, 21, 0.12); transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1), top 100ms ease, left 100ms ease, width 100ms ease, height 100ms ease; }", + ".preview.visible { opacity: 1; }", + "@media (prefers-color-scheme: dark) { .preview { border-color: rgba(56, 189, 248, 0.6); background: rgba(56, 189, 248, 0.12); mix-blend-mode: screen; box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.1), inset 0 0 12px rgba(56, 189, 248, 0.08); } }", + ".scope-badge { z-index: 100001; display: none; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 10px; font-weight: 700; line-height: 1; letter-spacing: 0.04em; text-transform: uppercase; color: #92400e; background: rgba(253, 230, 138, 0.92); border: 1px solid rgba(250, 204, 21, 0.4); padding: 3px 7px; border-radius: 6px; white-space: nowrap; }", + "@media (prefers-color-scheme: dark) { .scope-badge { color: #bae6fd; background: rgba(7, 89, 133, 0.88); border-color: rgba(56, 189, 248, 0.35); } }", + ".warn-badge { z-index: 100001; display: none; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 10px; font-weight: 700; line-height: 1; color: #b91c1c; background: rgba(254, 226, 226, 0.94); border: 1px solid rgba(248, 113, 113, 0.4); padding: 3px 7px; border-radius: 6px; white-space: nowrap; }", + "@media (prefers-color-scheme: dark) { .warn-badge { color: #fca5a5; background: rgba(127, 29, 29, 0.88); border-color: rgba(248, 113, 113, 0.35); } }", + ".feedback { z-index: 99999; opacity: 0; border: 1px solid rgba(110, 231, 183, 0.96); border-radius: 8px; background: linear-gradient(135deg, rgba(45, 212, 191, 0.22), rgba(34, 197, 94, 0.18)); box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.14), 0 0 28px rgba(45, 212, 191, 0.35); }", + ".feedback.visible { animation: feedbackPulse 1150ms cubic-bezier(0.16, 1, 0.3, 1) forwards; }", + "@keyframes feedbackPulse { 0% { opacity: 0.96; transform: scale(0.985); } 58% { opacity: 0.7; transform: scale(1); } 100% { opacity: 0; transform: scale(1.02); } }", + ".cursor-toast { z-index: 100000; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 12px; font-weight: 650; line-height: 1; letter-spacing: 0.01em; color: #ecfeff; white-space: nowrap; padding: 7px 10px; border-radius: 999px; border: 1px solid rgba(125, 211, 252, 0.32); background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(8, 47, 73, 0.92)); box-shadow: 0 10px 24px rgba(2, 6, 23, 0.28), 0 0 18px rgba(45, 212, 191, 0.24); animation: cursorToastFloat 1250ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }", + "@keyframes cursorToastFloat { 0% { opacity: 0; transform: translateY(4px); } 12% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-28px); } }", + "@media (prefers-reduced-motion: reduce) { .preview { transition: none; } .feedback.visible { animation-duration: 350ms; } .cursor-toast { animation-duration: 700ms; } }", + ].join("\n"); + + const layer = document.createElement("div"); + layer.className = "layer"; + + const preview = document.createElement("div"); + preview.className = "preview"; + + const feedback = document.createElement("div"); + feedback.className = "feedback"; + + const scopeBadge = document.createElement("div"); + scopeBadge.className = "scope-badge"; + + const warnBadge = document.createElement("div"); + warnBadge.className = "warn-badge"; + + layer.appendChild(preview); + layer.appendChild(feedback); + layer.appendChild(scopeBadge); + layer.appendChild(warnBadge); + shadowRoot.appendChild(style); + shadowRoot.appendChild(layer); + + (document.documentElement || document.body).appendChild(host); + + context.state.overlayState = { + host: host, + layer: layer, + preview: preview, + feedback: feedback, + scopeBadge: scopeBadge, + warnBadge: warnBadge, + }; + + return context.state.overlayState; + } + + return { + shouldShowPreview, + schedulePreviewUpdate, + renderPreview, + hidePreview, + showCopyFeedback, + getCopyToastText, + getToastPageX, + getToastPageY, + spawnCursorToast, + showStatusToast, + positionOverlayBox, + hasRenderableRect, + restartAnimation, + getOverlayState, + }; +} + +module.exports = { + createContentOverlay, +}; diff --git a/src/content/persistence.js b/src/content/persistence.js new file mode 100644 index 0000000..3303140 --- /dev/null +++ b/src/content/persistence.js @@ -0,0 +1,85 @@ +function createContentPersistence(context) { + const utils = context.utils; + + function settings() { + return context.state.settings; + } + + async function saveHistory(text, result, source, isSelectionBased) { + if (!settings().copyHistoryLimit) { + return; + } + + try { + const current = await utils.safeStorageGet("local", { copyHistory: [] }); + const nextHistory = utils.pushHistoryEntry(current.copyHistory, { + text: text, + snippet: utils.getTextSnippet(text), + source: source, + mode: "copy", + url: window.location.href, + hostname: window.location.hostname, + pinned: false, + replayCount: 0, + lastReplayedAt: isSelectionBased ? Date.now() : 0, + }, settings().copyHistoryLimit); + + await utils.safeStorageSet("local", { copyHistory: nextHistory }); + } catch (error) { + if (context.handleExtensionContextError(error)) { + return; + } + + console.warn("Saving copy history failed.", error); + } + } + + async function saveAnalyticsEvent(event) { + return saveAnalyticsEvents([event]); + } + + async function saveAnalyticsEvents(events) { + try { + const current = await utils.safeStorageGet("local", { copyAnalytics: utils.DEFAULT_ANALYTICS }); + let nextAnalytics = current.copyAnalytics; + (Array.isArray(events) ? events : [events]).forEach(function (event) { + nextAnalytics = utils.recordAnalyticsEvent(nextAnalytics, event || {}); + }); + await utils.safeStorageSet("local", { copyAnalytics: nextAnalytics }); + } catch (error) { + if (context.handleExtensionContextError(error)) { + return; + } + + console.warn("Saving copy analytics failed.", error); + } + } + + function getAnalyticsTypeForResult(result, source, isSelectionBased) { + if (source == "history") { + return "historyReplayCopy"; + } + + if (isSelectionBased) { + return "selectionCopy"; + } + + return "copy"; + } + + function getToastAnalyticsKind(result) { + return "copied"; + } + + return { + saveHistory, + saveAnalyticsEvent, + saveAnalyticsEvents, + getAnalyticsTypeForResult, + getToastAnalyticsKind, + }; +} + +module.exports = { + createContentPersistence, +}; diff --git a/src/content/targeting.js b/src/content/targeting.js new file mode 100644 index 0000000..7ef0b82 --- /dev/null +++ b/src/content/targeting.js @@ -0,0 +1,470 @@ +function createContentTargeting(context, dependencies) { + const utils = context.utils; + const hoverState = context.state.hoverState; + + const SCOPE_WORD = 0; + const SCOPE_SENTENCE = 1; + const SCOPE_PARAGRAPH = 2; + const SCOPE_CONTAINER = 3; + + function settings() { + return context.state.settings; + } + + function extraction() { + return dependencies.getExtraction(); + } + + function pierceShadowDOM(element, x, y) { + if (!element) { + return element; + } + const root = element.shadowRoot; + if (!root) { + return element; + } + const deeper = root.elementFromPoint(x, y); + if (!deeper || deeper === element) { + return element; + } + return pierceShadowDOM(deeper, x, y); + } + + function syncPointerState(event) { + hoverState.pointerPageX = event.pageX; + hoverState.pointerPageY = event.pageY; + hoverState.pointerClientX = event.clientX; + hoverState.pointerClientY = event.clientY; + } + + function resolveScopedTarget(sourceNode, clientX, clientY, level) { + if (level <= SCOPE_WORD) { + return null; + } + + const caretRange = getCaretRangeAtPoint(clientX, clientY); + if (!caretRange) { + return null; + } + + const textNode = caretRange.startContainer; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + return null; + } + + if (level === SCOPE_SENTENCE) { + const sentenceRange = expandToSentence(textNode, caretRange.startOffset); + if (sentenceRange) { + return { + kind: "scope", + node: textNode, + range: sentenceRange, + rect: getRangeBoundingRect(sentenceRange), + text: sentenceRange.toString().trim(), + }; + } + } + + if (level === SCOPE_PARAGRAPH) { + let paragraphElement = getElementNode(textNode); + while (paragraphElement && paragraphElement !== document.body) { + const display = window.getComputedStyle ? window.getComputedStyle(paragraphElement).display : ""; + if (display === "block" || display === "list-item" || display === "flex" || paragraphElement.nodeName === "P" || paragraphElement.nodeName === "LI") { + break; + } + paragraphElement = paragraphElement.parentElement; + } + if (paragraphElement && paragraphElement !== document.body) { + return createElementTarget(paragraphElement); + } + } + + if (level === SCOPE_CONTAINER) { + const container = getElementNode(textNode); + if (container && container.parentElement && container.parentElement !== document.body && container.parentElement !== document.documentElement) { + return createElementTarget(container.parentElement); + } + } + + return null; + } + + function expandToSentence(textNode, offset) { + const text = textNode.textContent || ""; + if (!text.trim()) { + return null; + } + + const sentenceBreaks = /[.!?\u3002\uff01\uff1f]+[\s]*/g; + const sentences = []; + let lastEnd = 0; + let match; + while ((match = sentenceBreaks.exec(text)) !== null) { + sentences.push({ start: lastEnd, end: match.index + match[0].length }); + lastEnd = match.index + match[0].length; + } + if (lastEnd < text.length) { + sentences.push({ start: lastEnd, end: text.length }); + } + + if (sentences.length === 0) { + sentences.push({ start: 0, end: text.length }); + } + + let target = sentences[0]; + for (let index = 0; index < sentences.length; index += 1) { + if (offset >= sentences[index].start && offset <= sentences[index].end) { + target = sentences[index]; + break; + } + } + + const range = document.createRange(); + range.setStart(textNode, target.start); + range.setEnd(textNode, Math.min(target.end, text.length)); + return range; + } + + function resolvePrecisionTarget(sourceNode, options) { + const localContext = options || {}; + const sourceElement = getElementNode(sourceNode); + const clientX = Number.isFinite(localContext.clientX) ? localContext.clientX : hoverState.pointerClientX; + const clientY = Number.isFinite(localContext.clientY) ? localContext.clientY : hoverState.pointerClientY; + + if (localContext.preferSelection !== false) { + const selectionTarget = getSelectionTarget(sourceElement, clientX, clientY); + if (selectionTarget) { + return selectionTarget; + } + } + + const scopeClientX = hoverState.scopeAnchorClientX !== null ? hoverState.scopeAnchorClientX : clientX; + const scopeClientY = hoverState.scopeAnchorClientY !== null ? hoverState.scopeAnchorClientY : clientY; + if (hoverState.scopeLevel > SCOPE_WORD) { + const scopedTarget = resolveScopedTarget(sourceElement, scopeClientX, scopeClientY, hoverState.scopeLevel); + if (scopedTarget) { + return scopedTarget; + } + } + + const deepTextTarget = getDeepTextTarget(clientX, clientY); + const fallbackElement = getDeepElementTarget(sourceElement, clientX, clientY); + const preliminaryTarget = deepTextTarget || createElementTarget(fallbackElement); + if (!preliminaryTarget) { + return null; + } + + const extractionContext = extraction().resolveExtractionContext(preliminaryTarget); + if (!extractionContext) { + return preliminaryTarget; + } + + switch (extractionContext.kind) { + case "selection": + case "scope": + return extractionContext; + case "table": + return { kind: "table", table: extractionContext.table, rect: extractionContext.table.getBoundingClientRect(), node: extractionContext.table }; + case "code": + return { kind: "code", container: extractionContext.container, rect: extractionContext.container.getBoundingClientRect(), node: extractionContext.container }; + case "list": + return { kind: "list", container: extractionContext.container, rect: extractionContext.container.getBoundingClientRect(), node: extractionContext.container }; + case "link": + return { kind: "link", anchor: extractionContext.anchor, rect: extractionContext.anchor.getBoundingClientRect(), node: extractionContext.anchor }; + case "image": + return { kind: "image", element: extractionContext.element, rect: extractionContext.element.getBoundingClientRect(), node: extractionContext.element }; + case "control": + return { kind: "control", element: extractionContext.element, rect: extractionContext.element.getBoundingClientRect(), node: extractionContext.element }; + case "text": { + let rect = null; + if (extractionContext.node && extractionContext.node.nodeType === Node.TEXT_NODE) { + const textRange = document.createRange(); + textRange.selectNodeContents(extractionContext.node); + rect = getRangeBoundingRect(textRange); + } else if (extractionContext.node && extractionContext.node.getBoundingClientRect) { + rect = extractionContext.node.getBoundingClientRect(); + } + return { kind: "text", node: extractionContext.node, rect: rect || preliminaryTarget.rect }; + } + default: + return preliminaryTarget; + } + } + + function getSelectionTarget(sourceElement, clientX, clientY) { + const selection = window.getSelection ? window.getSelection() : null; + if (selection && selection.rangeCount && !selection.isCollapsed) { + const selectionText = String(selection.toString() || "").trim(); + if (selectionText) { + const range = selection.getRangeAt(0).cloneRange(); + const rect = getRangeBoundingRect(range); + const commonNode = selection.anchorNode || range.commonAncestorContainer; + if ((!Number.isFinite(clientX) || !Number.isFinite(clientY)) || isPointInsideRect(rect, clientX, clientY) || (sourceElement && commonNode && sourceElement.contains(getElementNode(commonNode)))) { + return { + kind: "selection", + range: range, + rect: rect, + text: selectionText, + node: commonNode, + }; + } + } + } + + const activeElement = document.activeElement; + if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { + const start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; + const end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; + if (end > start) { + const selectedText = String(activeElement.value || "").slice(start, end).trim(); + if (selectedText && (!sourceElement || sourceElement === activeElement || activeElement.contains(sourceElement))) { + return { + kind: "selection", + rect: activeElement.getBoundingClientRect(), + text: selectedText, + node: activeElement, + }; + } + } + } + + return null; + } + + function getDeepTextTarget(clientX, clientY) { + if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return null; + } + + const range = getCaretRangeAtPoint(clientX, clientY); + if (!range) { + return null; + } + + const node = range.startContainer; + if (node && node.nodeType == Node.TEXT_NODE && String(node.textContent || "").trim() && isNodeVisible(node)) { + const textRange = document.createRange(); + textRange.selectNodeContents(node); + return { + kind: "text", + node: node, + rect: getRangeBoundingRect(textRange), + }; + } + + return null; + } + + function getDeepElementTarget(sourceElement, clientX, clientY) { + const element = Number.isFinite(clientX) && Number.isFinite(clientY) + ? pierceShadowDOM(document.elementFromPoint(clientX, clientY), clientX, clientY) + : sourceElement; + return getClosestMeaningfulElement(element || sourceElement); + } + + function getCaretRangeAtPoint(clientX, clientY) { + if (document.caretPositionFromPoint) { + const position = document.caretPositionFromPoint(clientX, clientY); + if (position && position.offsetNode) { + if (position.offsetNode.nodeType !== Node.TEXT_NODE && position.offset > position.offsetNode.childNodes.length) { + return null; + } + + const range = document.createRange(); + try { + range.setStart(position.offsetNode, position.offset); + range.setEnd(position.offsetNode, position.offset); + return range; + } catch (error) { + return null; + } + } + } + + if (document.caretRangeFromPoint) { + return document.caretRangeFromPoint(clientX, clientY); + } + + return null; + } + + function getClosestMeaningfulElement(element) { + let current = getElementNode(element); + while (current) { + const tagName = current.nodeName.toUpperCase(); + if (hasMeaningfulText(current) || tagName == "IMG" || tagName == "INPUT" || tagName == "TEXTAREA" || tagName == "SELECT") { + return current; + } + current = current.parentElement; + } + return getElementNode(element); + } + + function createElementTarget(element) { + if (!element) { + return null; + } + + return { + kind: "element", + node: element, + rect: element.getBoundingClientRect ? element.getBoundingClientRect() : null, + }; + } + + function hasMeaningfulText(node) { + if (!node) { + return false; + } + if (node.nodeType == Node.TEXT_NODE) { + return String(node.textContent || "").trim().length > 0; + } + if (node.nodeType != Node.ELEMENT_NODE) { + return false; + } + if (node.nodeName.toUpperCase() == "IMG") { + return !!extraction().getImageText(node); + } + return extraction().collectVisibleText(node).length > 0; + } + + function isNodeVisible(node) { + const element = getElementNode(node); + if (!element) { + return false; + } + if (element.hidden || element.getAttribute("aria-hidden") == "true") { + return false; + } + const style = window.getComputedStyle ? window.getComputedStyle(element) : null; + if (style && (style.display == "none" || style.visibility == "hidden")) { + return false; + } + return true; + } + + function getRangeBoundingRect(range) { + if (!range) { + return null; + } + + const rects = Array.from(range.getClientRects ? range.getClientRects() : []).filter(function (rect) { + return rect.width > 0 && rect.height > 0; + }); + if (!rects.length) { + const fallback = range.getBoundingClientRect ? range.getBoundingClientRect() : null; + return fallback && fallback.width > 0 && fallback.height > 0 ? fallback : null; + } + + return rects.reduce(function (acc, current) { + if (!acc) { + return { + left: current.left, + top: current.top, + right: current.right, + bottom: current.bottom, + width: current.width, + height: current.height, + }; + } + + const left = Math.min(acc.left, current.left); + const top = Math.min(acc.top, current.top); + const right = Math.max(acc.right, current.right); + const bottom = Math.max(acc.bottom, current.bottom); + return { + left: left, + top: top, + right: right, + bottom: bottom, + width: right - left, + height: bottom - top, + }; + }, null); + } + + function isPointInsideRect(rect, clientX, clientY) { + if (!rect || !Number.isFinite(clientX) || !Number.isFinite(clientY)) { + return false; + } + return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom; + } + + function shouldIgnoreElement(node) { + return settings().avoidEditable && utils.isEditableSurface(getElementNode(node)); + } + + function resolveShortcutTarget() { + if (hoverState.hoveredElement && hoverState.hoveredElement.isConnected) { + return hoverState.hoveredElement; + } + + if (document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement) { + return document.activeElement; + } + + const selection = window.getSelection ? window.getSelection() : null; + if (selection && selection.anchorNode) { + return selection.anchorNode; + } + + return null; + } + + function getNativeCopiedText() { + const activeElement = document.activeElement; + if (activeElement && (activeElement.nodeName == "INPUT" || activeElement.nodeName == "TEXTAREA")) { + const start = typeof activeElement.selectionStart == "number" ? activeElement.selectionStart : 0; + const end = typeof activeElement.selectionEnd == "number" ? activeElement.selectionEnd : 0; + const value = String(activeElement.value || ""); + if (end > start) { + return value.slice(start, end).trim(); + } + } + + const selection = window.getSelection ? window.getSelection() : null; + return selection ? String(selection.toString() || "").trim() : ""; + } + + function getElementNode(node) { + if (!node) { + return null; + } + + if (node.nodeType == Node.TEXT_NODE) { + return node.parentNode; + } + + return node.nodeType == Node.ELEMENT_NODE ? node : null; + } + + return { + SCOPE_WORD, + SCOPE_SENTENCE, + SCOPE_PARAGRAPH, + SCOPE_CONTAINER, + pierceShadowDOM, + syncPointerState, + resolveScopedTarget, + expandToSentence, + resolvePrecisionTarget, + getSelectionTarget, + getDeepTextTarget, + getDeepElementTarget, + getCaretRangeAtPoint, + getClosestMeaningfulElement, + createElementTarget, + hasMeaningfulText, + isNodeVisible, + getRangeBoundingRect, + isPointInsideRect, + shouldIgnoreElement, + resolveShortcutTarget, + getNativeCopiedText, + getElementNode, + }; +} + +module.exports = { + createContentTargeting, +}; diff --git a/tests/e2e/extension.spec.js b/tests/e2e/extension.spec.js index 05e34fb..d544fed 100644 --- a/tests/e2e/extension.spec.js +++ b/tests/e2e/extension.spec.js @@ -118,6 +118,24 @@ async function openExtensionPage(relativePath) { return page; } +async function updateSyncSettings(settings) { + const extensionPage = await openExtensionPage("popup.html"); + await extensionPage.evaluate(async function (nextSettings) { + await chrome.storage.sync.set(nextSettings); + }, settings); + await extensionPage.close(); +} + +async function readLatestHistoryText() { + const extensionPage = await openExtensionPage("popup.html"); + const text = await extensionPage.evaluate(async function () { + const items = await chrome.storage.local.get({ copyHistory: [] }); + return items.copyHistory && items.copyHistory[0] ? items.copyHistory[0].text : ""; + }); + await extensionPage.close(); + return text; +} + async function withFreshExtensionContext(callback) { await closeExtensionContext(); await launchExtensionContext(); @@ -153,6 +171,51 @@ test("copies basic paragraph text", async function () { await page.close(); }); +test("copies rich targets from the basic fixture", async function () { + await updateSyncSettings({ avoidEditable: false }); + const { page } = await openPage("fixtures/basic-copy.html"); + try { + await altClick(page.locator("a[href='https://example.com/docs/guide']")); + await expect.poll(async function () { + return readClipboard(page); + }).toBe("[documentation link](https://example.com/docs/guide)"); + + await altClick(page.locator("input[type='text']")); + await expect.poll(async function () { + return readClipboard(page); + }).toBe("Input field text"); + + await altClick(page.locator("textarea")); + await expect.poll(async function () { + return readClipboard(page); + }).toContain("Textarea content line 1"); + + await altClick(page.locator("select")); + await expect.poll(async function () { + return readClipboard(page); + }).toBe("Beta"); + } finally { + await page.close(); + await updateSyncSettings({ avoidEditable: true }); + } +}); + +test("copies tables as TSV without hidden cells", async function () { + const { page } = await openPage("fixtures/table-copy.html"); + await altClick(page.locator("td", { hasText: "Analytics Hub" })); + + await expect.poll(async function () { + return readLatestHistoryText(); + }).toBe([ + "Product\tOwner\tStatus", + "Editor Suite\tAna\tBeta", + "Analytics Hub\tMarco\tLive", + "Docs Cloud\tTrang SEA Region\tPlanning", + ].join("\n")); + + await page.close(); +}); + test("prefers the selected text over the whole element", async function () { const { page } = await openPage("fixtures/selection-copy.html"); await page.evaluate(function () { From 1c6e222847038d35b966bf7c752f21b773a63a37 Mon Sep 17 00:00:00 2001 From: VET Date: Tue, 19 May 2026 10:32:25 +0700 Subject: [PATCH 06/12] Prevent page handlers from swallowing Alt-click copy Some pages handle form-button clicks before document bubble listeners can observe them. Google Search is one visible case: the primary search button can run page click behavior while the extension is still waiting for the bubble-phase content listener. The content click listener now runs in capture phase and claims only recognized copy gestures, so normal clicks and excluded/editable-skip cases retain existing behavior while Alt-click copy bypasses page handlers and form submission. Constraint: Chrome-only content script behavior must keep dist/chrome as the release surface Rejected: Add a google.com-specific selector workaround | brittle and misses the underlying propagation issue Rejected: Stop propagation for all clicks | would break host-page behavior outside copy gestures Confidence: high Scope-risk: moderate Directive: Keep propagation blocking scoped to confirmed copy gestures only Tested: npm run verify:release Not-tested: Manual visible Chrome test on live google.com --- fixtures/google-like-buttons.html | 55 +++++++++++++++++++++++++++++++ src/content/bootstrap.js | 4 +-- src/content/events.js | 17 +++++++++- tests/e2e/extension.spec.js | 26 +++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 fixtures/google-like-buttons.html diff --git a/fixtures/google-like-buttons.html b/fixtures/google-like-buttons.html new file mode 100644 index 0000000..519f46f --- /dev/null +++ b/fixtures/google-like-buttons.html @@ -0,0 +1,55 @@ + + + + + + Copy Extension Fixture - Google-like Buttons + + + +

Google-like form buttons

+

These buttons simulate search page controls that can handle or stop click propagation.

+ +
+ + + +
+ + + + diff --git a/src/content/bootstrap.js b/src/content/bootstrap.js index de8df93..a633f64 100644 --- a/src/content/bootstrap.js +++ b/src/content/bootstrap.js @@ -159,7 +159,7 @@ } function attachDomListeners() { - document.addEventListener("click", events.handleClick, false); + document.addEventListener("click", events.handleClick, true); document.addEventListener("mousemove", events.handleMouseMove, true); document.addEventListener("mouseover", events.handleMouseOver, true); document.addEventListener("mouseout", events.handleMouseOut, true); @@ -173,7 +173,7 @@ } function removeDomListeners() { - document.removeEventListener("click", events.handleClick, false); + document.removeEventListener("click", events.handleClick, true); document.removeEventListener("mousemove", events.handleMouseMove, true); document.removeEventListener("mouseover", events.handleMouseOver, true); document.removeEventListener("mouseout", events.handleMouseOut, true); diff --git a/src/content/events.js b/src/content/events.js index a70bdb2..36fb298 100644 --- a/src/content/events.js +++ b/src/content/events.js @@ -27,6 +27,8 @@ function createContentEvents(context) { return; } + claimCopyGesture(event); + const targetToCopy = hoverState.lastRenderedTarget; helpers.syncPointerState(event); @@ -51,8 +53,21 @@ function createContentEvents(context) { console.error("Copy failed.", error); }); } + } - event.preventDefault(); + function claimCopyGesture(event) { + if (typeof event.preventDefault === "function" && event.cancelable !== false) { + event.preventDefault(); + } + + if (typeof event.stopImmediatePropagation === "function") { + event.stopImmediatePropagation(); + return; + } + + if (typeof event.stopPropagation === "function") { + event.stopPropagation(); + } } function handleMouseMove(event) { diff --git a/tests/e2e/extension.spec.js b/tests/e2e/extension.spec.js index d544fed..9087f33 100644 --- a/tests/e2e/extension.spec.js +++ b/tests/e2e/extension.spec.js @@ -200,6 +200,32 @@ test("copies rich targets from the basic fixture", async function () { } }); +test("captures form button copy before page click handlers", async function () { + const { page } = await openPage("fixtures/google-like-buttons.html"); + + await altClick(page.locator("#google_search_button")); + await expect.poll(async function () { + return readClipboard(page); + }).toBe("Google Search"); + await expect(page.locator("body")).toBeVisible(); + expect(await page.evaluate(function () { + return { + submitCount: window.fixtureSubmitCount, + searchButtonClickCount: window.fixtureSearchButtonClickCount, + }; + })).toEqual({ + submitCount: 0, + searchButtonClickCount: 0, + }); + + await altClick(page.locator("#lucky_button")); + await expect.poll(async function () { + return readClipboard(page); + }).toBe("I'm Feeling Lucky"); + + await page.close(); +}); + test("copies tables as TSV without hidden cells", async function () { const { page } = await openPage("fixtures/table-copy.html"); await altClick(page.locator("td", { hasText: "Analytics Hub" })); From 44d3f65f4844530120ff0cbd90b80ccebb751bba Mon Sep 17 00:00:00 2001 From: VET Date: Thu, 11 Jun 2026 15:13:25 +0700 Subject: [PATCH 07/12] Make the Windows companion reliable for offline history The extension can now treat the local Companion as the source of truth when the native host is connected, while keeping a local fallback and IndexedDB-backed outbox for offline work. The companion adds the Wails desktop MVP, local JSON history, schema v3 conflict timestamps and tombstones, smart previews, tray/hotkey/autostart support, Windows native-host install scripts, release docs, and native/e2e smoke coverage. The final review pass also tightened the native messaging transport contract: host responses are capped under Chrome's 1 MiB host-to-extension limit, extension request text is truncated by UTF-8 bytes, and offline pinning now fails open without trimming fallback history if sync settings cannot be read. Constraint: No cloud sync, account, telemetry, or network transport; clipboard data stays local. Constraint: Native host name remains com.copy_text_without_selecting.companion and protocol version remains 1. Constraint: Chrome native messaging host-to-extension responses must stay under 1 MiB. Rejected: chrome.storage.local as the primary outbox | quota and data-loss risk for large pending payloads. Rejected: embedding companion/build/appicon.png | fresh workspaces do not contain generated build output. Confidence: high Scope-risk: broad Reversibility: messy Directive: Do not remove pending outbox operations until Companion ACKs the corresponding native message. Directive: Keep source assets under companion/assets and companion/frontend/dist; companion/build stays generated output. Directive: Keep native messaging request and response byte budgets separate; extension text truncation must remain UTF-8 byte-based, not JavaScript string-length-based. Tested: npm run validate; npm run test:e2e; npm run native:build; npm run native:test-host; npm run native:test-tray; npm run test:e2e:native; npm run pack:chrome; targeted node --test tests/native-messaging.test.js tests/local-history.test.js; targeted go test ./internal/companion; fresh workspace npm ci + validate + native:build; Wails desktop launch screenshot smoke; git diff --check Not-tested: macOS/Linux native hosts; Chrome Web Store submission; real end-user migration beyond synthetic schema/outbox fixtures. --- .gitignore | 4 + README.md | 55 +- companion/README.md | 107 +++ companion/app.go | 263 ++++++ companion/assets/appicon.png | Bin 0 -> 3535 bytes companion/autostart_other.go | 7 + companion/autostart_windows.go | 45 + companion/autostart_windows_test.go | 67 ++ companion/frontend/dist/app.css | 382 ++++++++ companion/frontend/dist/app.js | 360 ++++++++ companion/frontend/dist/index.html | 80 ++ companion/go.mod | 36 + companion/go.sum | 83 ++ companion/hotkey_other.go | 19 + companion/hotkey_windows.go | 191 ++++ companion/hotkey_windows_test.go | 23 + .../internal/companion/filelock_other.go | 13 + .../internal/companion/filelock_windows.go | 77 ++ companion/internal/companion/paths.go | 24 + companion/internal/companion/protocol.go | 298 +++++++ companion/internal/companion/protocol_test.go | 275 ++++++ companion/internal/companion/service.go | 185 ++++ companion/internal/companion/service_test.go | 596 +++++++++++++ companion/internal/companion/smart.go | 450 ++++++++++ companion/internal/companion/smart_test.go | 74 ++ companion/internal/companion/store.go | 844 ++++++++++++++++++ companion/internal/companion/types.go | 126 +++ companion/main.go | 100 +++ companion/main_test.go | 43 + companion/tray_other.go | 21 + companion/tray_windows.go | 436 +++++++++ companion/wails.json | 16 + docs/release-companion-checklist.md | 79 ++ fixtures/native-companion.html | 17 + manifest.json | 1 + options.css | 18 + package.json | 7 + popup.css | 42 + popup.html | 4 + scripts/build-companion.cjs | 79 ++ scripts/check-companion.cjs | 33 + scripts/e2e-native-companion.cjs | 639 +++++++++++++ scripts/install-native-host-windows.cjs | 176 ++++ scripts/lib/native-tools.cjs | 41 + scripts/smoke-native-host.cjs | 142 +++ scripts/smoke-tray-windows.cjs | 574 ++++++++++++ scripts/uninstall-native-host-windows.cjs | 47 + src/background/index.js | 22 +- src/background/local-history.js | 776 ++++++++++++++++ src/background/native-messaging.js | 407 +++++++++ src/content/persistence.js | 77 +- src/options/history-view.js | 143 ++- src/popup/history-view.js | 127 ++- src/popup/index.js | 53 ++ src/shared/core.js | 412 ++++++++- tests/local-history-failure.test.js | 124 +++ tests/local-history.test.js | 658 ++++++++++++++ tests/native-messaging.test.js | 219 +++++ tests/release-utils.test.js | 16 + tests/shared.test.js | 92 ++ 60 files changed, 10255 insertions(+), 70 deletions(-) create mode 100644 companion/README.md create mode 100644 companion/app.go create mode 100644 companion/assets/appicon.png create mode 100644 companion/autostart_other.go create mode 100644 companion/autostart_windows.go create mode 100644 companion/autostart_windows_test.go create mode 100644 companion/frontend/dist/app.css create mode 100644 companion/frontend/dist/app.js create mode 100644 companion/frontend/dist/index.html create mode 100644 companion/go.mod create mode 100644 companion/go.sum create mode 100644 companion/hotkey_other.go create mode 100644 companion/hotkey_windows.go create mode 100644 companion/hotkey_windows_test.go create mode 100644 companion/internal/companion/filelock_other.go create mode 100644 companion/internal/companion/filelock_windows.go create mode 100644 companion/internal/companion/paths.go create mode 100644 companion/internal/companion/protocol.go create mode 100644 companion/internal/companion/protocol_test.go create mode 100644 companion/internal/companion/service.go create mode 100644 companion/internal/companion/service_test.go create mode 100644 companion/internal/companion/smart.go create mode 100644 companion/internal/companion/smart_test.go create mode 100644 companion/internal/companion/store.go create mode 100644 companion/internal/companion/types.go create mode 100644 companion/main.go create mode 100644 companion/main_test.go create mode 100644 companion/tray_other.go create mode 100644 companion/tray_windows.go create mode 100644 companion/wails.json create mode 100644 docs/release-companion-checklist.md create mode 100644 fixtures/native-companion.html create mode 100644 scripts/build-companion.cjs create mode 100644 scripts/check-companion.cjs create mode 100644 scripts/e2e-native-companion.cjs create mode 100644 scripts/install-native-host-windows.cjs create mode 100644 scripts/lib/native-tools.cjs create mode 100644 scripts/smoke-native-host.cjs create mode 100644 scripts/smoke-tray-windows.cjs create mode 100644 scripts/uninstall-native-host-windows.cjs create mode 100644 src/background/local-history.js create mode 100644 src/background/native-messaging.js create mode 100644 tests/local-history-failure.test.js create mode 100644 tests/local-history.test.js create mode 100644 tests/native-messaging.test.js diff --git a/.gitignore b/.gitignore index 74a1d90..e6b63d8 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,10 @@ Temporary Items web-ext-artifacts dist/ +!companion/frontend/dist/ +!companion/frontend/dist/** +companion/build/ +companion/frontend/wailsjs/ .omx/ node_modules/ test-results/ diff --git a/README.md b/README.md index 6973297..71983c3 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Easy and fast copy tool for Chrome MV3. - Local analytics dashboard for copy, shortcut, block, domain, and selection activity - Keyboard shortcut mode for hovered or focused targets - Safe mode to skip rich-text editors and editable app surfaces +- Developer smart actions for JSON, SQL, JWT, timestamps, case transforms, and Base64 +- Optional Windows companion app with local clipboard history and smart preview ## Download @@ -34,6 +36,7 @@ The clicked text is copied immediately without manual selection in the common ca - `src/popup/` contains quick-control popup source modules - `src/options/` contains settings and history-management source modules - `src/shared/` contains shared settings, history, analytics, i18n, and runtime-safe Chrome API helpers +- `companion/` contains the optional Windows Wails/native-messaging companion app - `dist/chrome/` is the generated unpacked extension artifact ## Settings @@ -43,7 +46,7 @@ The clicked text is copied immediately without manual selection in the common ca - **Skip editable apps**: avoids contenteditable editors and rich text surfaces - **Feedback duration**: controls how long copy feedback remains visible - **Excluded domains**: one hostname per line, matched against the host and its subdomains -- **Extension language**: `Auto`, `English`, or `Tiếng Việt` +- **Extension language**: `Auto`, `English`, or `Vietnamese` - **Copy history size**: controls how many recent copied items remain available - **History search/filter**: filter by source, mode, and domain from the options page - **Local analytics**: summary cards and top domains, stored locally only @@ -64,6 +67,56 @@ Run the fast contract checks: npm run validate ``` +Build the optional Windows native companion host when Go is installed: + +```bash +npm run native:test +npm run native:build +npm run native:test-host +npm run native:test-tray +npm run native:install-host +npm run test:e2e:native +``` + +For unpacked Chrome testing, install the host with the unpacked extension ID: + +```powershell +$env:COPY_TEXT_DEV_EXTENSION_ID = "your_unpacked_extension_id" +npm run native:install-host +``` + +Remove the native host registration: + +```bash +npm run native:uninstall-host +``` + +The companion uses Chrome `nativeMessaging` only to talk to the local Windows +host `com.copy_text_without_selecting.companion`. History stays local under +`%APPDATA%\CopyTextWithoutSelecting`; the extension falls back to local mode +when the host is not installed. + +When the host is connected, Companion history is the authoritative list shown +by the extension. Copies and history mutations are first written to an +IndexedDB FIFO outbox, then acknowledged operations are removed after the native +host stores them. If IndexedDB is unavailable, the extension falls back to the +legacy `chrome.storage.local` outbox. Existing `copyHistorySyncOutbox` entries +are migrated into IndexedDB on first use. If the host is unavailable, the +extension keeps local history and pending add, pin, delete, and clear +operations. A later successful connection flushes them in order. Clear removes +older pending operations, and the Companion schema tracks operation timestamps +and tombstones so stale copies cannot be restored ahead of items created after +the clear. + +The Windows companion MVP currently supports the desktop window, system tray +show/minimize/quit actions, app menu shortcuts, global hotkey restore, local +settings, and optional Run-key auto-start. +`npm run native:test-tray` must run from an interactive Windows desktop because +it verifies the real notification-area icon and tray menu. It uses physical +mouse input when Windows permits input injection; restricted desktop sessions +fall back to posting the same Win32 tray callbacks and commands after verifying +the registered icon and menu labels. + Run the browser automation smoke suite: ```bash diff --git a/companion/README.md b/companion/README.md new file mode 100644 index 0000000..6cada51 --- /dev/null +++ b/companion/README.md @@ -0,0 +1,107 @@ +# Copy Text Companion + +Windows-first native companion for `copy-text-without-selecting`. + +This directory contains the Windows-first native companion MVP. It speaks the +Chrome Native Messaging protocol over stdio, runs a Wails desktop UI in normal +mode, and persists clipboard history to +`%APPDATA%\CopyTextWithoutSelecting\history.json`. + +## Modes + +- `copy-text-companion.exe --native-messaging`: launched by Chrome through the + registered native messaging host manifest. +- `copy-text-companion.exe`: Wails desktop history manager. + +## Protocol + +Requests use this envelope: + +```json +{ "id": "uuid", "version": 1, "type": "PING", "payload": {} } +``` + +Responses use this envelope: + +```json +{ "id": "uuid", "ok": true, "payload": {} } +``` + +Implemented message types: + +- `PING` +- `CLIPBOARD_EVENT` +- `HISTORY_LIST` +- `HISTORY_DELETE` +- `HISTORY_CLEAR` +- `HISTORY_PIN` +- `FORMAT_PREVIEW` +- `SETTINGS_GET` +- `SETTINGS_UPDATE` +- `OPEN_APP` +- `PRIVACY_GET` + +## History Synchronization + +The Companion JSON store is the source of truth while the native host is +connected. The extension keeps its own local history as an offline fallback +and stores native mutations in an IndexedDB outbox before sending them. Existing +legacy `copyHistorySyncOutbox` entries are migrated into IndexedDB +non-destructively on first use, and `chrome.storage.local` remains only a small +history cache plus a fallback if IndexedDB is unavailable. Pending operations +survive extension service-worker and browser restarts and are flushed FIFO after +reconnect. + +- `CLIPBOARD_EVENT` is an idempotent upsert because Companion IDs are derived + from copied text. +- Extension-local IDs may differ from Companion IDs. Delete and pin requests + therefore include text; Companion tries the supplied ID first and falls back + to the text-derived ID. +- Pin, unpin, delete, and later upserts carry `operationAt` timestamps. + Companion schema v3 stores `updatedAt`, `pinUpdatedAt`, `clearedAt`, and + delete tombstones so older cross-writer mutations cannot override newer ones. +- Clear discards all older pending operations in the extension. Companion clear + removes only entries older than the clear operation, so items created after a + clear are preserved and stale upserts are not resurrected. +- A failed native response leaves the operation in the outbox for retry. + +## Build + +From the repository root: + +```powershell +npm run native:build +``` + +Register the Chrome native messaging host for the current user: + +```powershell +npm run native:install-host +``` + +For unpacked Chrome testing, include the unpacked extension ID: + +```powershell +$env:COPY_TEXT_DEV_EXTENSION_ID="your_unpacked_extension_id" +npm run native:install-host +``` + +Remove the registration: + +```powershell +npm run native:uninstall-host +``` + +The install script copies the built executable under `%LOCALAPPDATA%`, writes +the host manifest under `%LOCALAPPDATA%`, and registers it under +the current user's Google Chrome and Chromium native-messaging registry keys. + +## Notes + +- Wails v2 menu accelerators are wired for show/hide/quit. Windows global + hotkey registration is enabled from the local hotkey setting at startup. +- Windows tray support is implemented with direct Win32 `Shell_NotifyIconW` + calls because Wails v2.12 does not expose a stable public tray option on + `options.App`. The tray icon reuses the embedded companion app icon and + provides Show, Minimize, and Quit actions. +- The companion has no cloud sync, telemetry, or network transport. diff --git a/companion/app.go b/companion/app.go new file mode 100644 index 0000000..0ba6af9 --- /dev/null +++ b/companion/app.go @@ -0,0 +1,263 @@ +package main + +import ( + "context" + "errors" + "log" + "sync" + "time" + + "copytextcompanion/internal/companion" + + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +type App struct { + service *companion.Service + + ctx context.Context + ctxMu sync.Mutex + stopWatch chan struct{} + hotkey *hotkeyManager + tray *trayManager + + clipboardMu sync.Mutex + selfClipboardText string +} + +func NewApp(service *companion.Service) *App { + return &App{ + service: service, + stopWatch: make(chan struct{}), + } +} + +func (app *App) startup(ctx context.Context) { + app.ctxMu.Lock() + app.ctx = ctx + app.ctxMu.Unlock() + + app.service.SetOpenAppHandler(func() bool { + return app.OpenApp() + }) + + app.hotkey = newHotkeyManager(func() { + app.OpenApp() + }) + if settings, err := app.service.GetSettings(); err == nil { + if err := app.hotkey.Update(settings.Hotkey); err != nil { + log.Printf("register global hotkey: %v", err) + } + } + app.tray = newTrayManager(trayActions{ + Show: app.OpenApp, + Minimize: app.HideApp, + Quit: app.Quit, + }) + if err := app.tray.Start(); err != nil { + log.Printf("start tray icon: %v", err) + } + go app.watchClipboard() +} + +func (app *App) shutdown(ctx context.Context) { + select { + case <-app.stopWatch: + default: + close(app.stopWatch) + } + if app.hotkey != nil { + app.hotkey.Stop() + } + if app.tray != nil { + app.tray.Stop() + } +} + +func (app *App) ListHistory(query string) ([]companion.HistoryEntry, error) { + return app.service.ListHistory(companion.ListHistoryOptions{Query: query, Limit: 250}) +} + +func (app *App) DeleteHistory(id string) (bool, error) { + return app.service.DeleteHistory(id) +} + +func (app *App) ClearHistory() (int, error) { + return app.service.ClearHistory() +} + +func (app *App) PinHistory(id string, pinned bool) (bool, error) { + return app.service.PinHistory(id, pinned) +} + +func (app *App) CopyText(text string) error { + ctx := app.context() + if ctx == nil { + return errors.New("app context is unavailable") + } + if err := wailsruntime.ClipboardSetText(ctx, text); err != nil { + return err + } + app.markSelfClipboardText(text) + return nil +} + +func (app *App) FormatPreview(text string, action string) companion.FormatPreviewResult { + return app.service.FormatPreview(text, action) +} + +func (app *App) GetSettings() (companion.Settings, error) { + return app.service.GetSettings() +} + +func (app *App) UpdateSettings(patch companion.SettingsPatch) (companion.Settings, error) { + previous, err := app.service.GetSettings() + if err != nil { + return companion.Settings{}, err + } + if patch.Hotkey != nil { + if err := validateHotkey(*patch.Hotkey); err != nil { + return companion.Settings{}, err + } + } + + settings, err := app.service.UpdateSettings(patch) + if err != nil { + return companion.Settings{}, err + } + appliedAutoStart := false + if patch.AutoStart != nil { + if err := configureAutoStart(settings.AutoStart); err != nil { + return companion.Settings{}, errors.Join(err, app.restoreSettings(previous, true, false)) + } + appliedAutoStart = true + } + if patch.Hotkey != nil && app.hotkey != nil { + if err := app.hotkey.Update(settings.Hotkey); err != nil { + return companion.Settings{}, errors.Join(err, app.restoreSettings(previous, appliedAutoStart, true)) + } + } + return settings, nil +} + +func (app *App) restoreSettings(previous companion.Settings, restoreAutoStart bool, restoreHotkey bool) error { + _, settingsErr := app.service.UpdateSettings(companion.SettingsPatch{ + HistoryEnabled: &previous.HistoryEnabled, + MaxItems: &previous.MaxItems, + AutoStart: &previous.AutoStart, + Hotkey: &previous.Hotkey, + }) + + var autoStartErr error + if restoreAutoStart { + autoStartErr = configureAutoStart(previous.AutoStart) + } + + var hotkeyErr error + if restoreHotkey && app.hotkey != nil { + hotkeyErr = app.hotkey.Update(previous.Hotkey) + } + + return errors.Join(settingsErr, autoStartErr, hotkeyErr) +} + +func (app *App) OpenApp() bool { + ctx := app.context() + if ctx == nil { + return false + } + wailsruntime.WindowShow(ctx) + wailsruntime.WindowUnminimise(ctx) + return true +} + +func (app *App) HideApp() bool { + ctx := app.context() + if ctx == nil { + return false + } + wailsruntime.WindowMinimise(ctx) + return true +} + +func (app *App) Quit() bool { + ctx := app.context() + if ctx == nil { + return false + } + wailsruntime.Quit(ctx) + return true +} + +func (app *App) StorePath() string { + return app.service.StorePath() +} + +func (app *App) context() context.Context { + app.ctxMu.Lock() + defer app.ctxMu.Unlock() + return app.ctx +} + +func (app *App) watchClipboard() { + ticker := time.NewTicker(1400 * time.Millisecond) + defer ticker.Stop() + + var lastText string + for { + select { + case <-app.stopWatch: + return + case <-ticker.C: + ctx := app.context() + if ctx == nil { + continue + } + text, err := wailsruntime.ClipboardGetText(ctx) + if err != nil || text == "" || text == lastText { + continue + } + settings, err := app.service.GetSettings() + if err != nil { + log.Printf("read companion settings: %v", err) + continue + } + if !settings.HistoryEnabled { + lastText = text + continue + } + if app.consumeSelfClipboardText(text) { + lastText = text + continue + } + _, err = app.service.RecordClipboardEvent(companion.ClipboardEvent{ + Source: "desktop-clipboard", + Mode: "copy", + Text: text, + CreatedAt: time.Now().UnixMilli(), + }) + if err != nil { + log.Printf("record clipboard history: %v", err) + continue + } else { + lastText = text + } + } + } +} + +func (app *App) markSelfClipboardText(text string) { + app.clipboardMu.Lock() + defer app.clipboardMu.Unlock() + app.selfClipboardText = text +} + +func (app *App) consumeSelfClipboardText(text string) bool { + app.clipboardMu.Lock() + defer app.clipboardMu.Unlock() + if app.selfClipboardText != text { + return false + } + app.selfClipboardText = "" + return true +} diff --git a/companion/assets/appicon.png b/companion/assets/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..3990d0ace6b7f79eba163d423f582ac9ba77f69e GIT binary patch literal 3535 zcmV;=4KVVFP)V!Z2XskIMF-Xf3l$av&yJ$S0000ObVXQn zQ*UN;cVTj60B~VxZgehgWpp4kE-)@O{ETDY000d-Nkl;Gy=zh@j99lv)( zIy@a4J%a{DIA3u@xF$IwJp%?rxCRW|Fz|QQC;xkv$3~AoD!?~9B3#LiNLQmP)=Nfg z9VwwHVe-nZsm4N5CYaZpF`n%nk*-Qdq%+bHr38N}Lf+xtzZe+p*&oo(D9^hilSa$* z+!+#EwMmX%J0>MJPD*LZX*u0;#u$%zt>D@bi8>!6({iSZf6}OSXO!!vM}%{^68Vd6 z`%K5~Kk^z7HQ-N<2hhHzQd+|AEr1LFDl&fXp-pR7%Vxkl_pO6#HMN)LV zScG-w9m52XOXaFZRs4dPp{3h!?*tBOdCR zE5gIWpS56ow}s~cAGl+@?5y1-S&i9})0k@xf(VhYiP&V^2|a4=pxuG9SMJq3vo~iX zS8taw$zw(HY)QZHe&3A*nnyMxl19q5OUZJuAzd=AWJ=bRY{_oSF$dbwc$CQFtf1+L z`7GbDz0O|jnRz8kG8!_?S)4gM(Z5|C_@2Uf`U*wDddCfEk2oJGd+Ybf{`xdIaQUF5 zH)NQDI1rE7L~JrPp-0Vr53@h-?sfKLZ_e0PzhCy$@0B$b>%vD?4g;N^0Hi4ic9;NMJnQcX_{gE$#9FInbc5yBS^tkZrc~Y>)FCC)nWmygT?^ zb*Yk4yF+Fa%oInI^NcFuR~Ed-FS4IHAG9l2wp1rda?N%_f(_Z219sQ#k+ZkX$%FO> zPh^eXG@9=rL3hyZlEDc!_?>mT4ESv|$+G!kyo^o>5UotJEr^d_ro%=$?=L7_BncO{ zNaCffJxGwy=Wd;s8+UI=SxdQ;->fi3*L81T*oD@M^38*9<8A-sJ$f?)) z%j@o*2Yh1nR*AouAoEKXLZHEWrT1?vn0+tCgTUS6CAum`;x245B(`2k>P7+}6*tdH z!<`0G7VUbEEGlnTO4aQuW0h(EzjgnXv1)yMOsmidtE}P(_5(ao?FL>Q2Y4_Gzbu1IhIgvxG~OZ}b8rYzds6@9nsznk}NO7q?3 z#~RXV(a*6b2AN*LS4PW*^AYmV@!6USp5(%F7TlhTsnRE5m@KPUE^E)NR}zti#HI_I z4GDmAK)gOiE_HY6Br)Jw{3X}6s9bvU{D$)zWzD&D zvZQ>Oc*PC6J0QmEuNK^%3;BfYpD4@9m&=N?t9p=NV-ASb&Cz#lTdf%Z>_(NL)dm89 z<+Xjt_U0_k#DO?+Y5CU%)7yvN5sxU>0Sj^W#V;vmw5QA|{#d>|y+9U~Etap#S9Bx6 z=CK#zOjwyFNlH31B=}2`LxAVKH8a~Nr*&=hn%K8HjhsMUc-$R4js*O?)AMDT^TltFk4A#HzDv%!Y&%FRebiR%KBr7qvvvq^Kg~1GxYJ z$RHED<34IcfM}U=s?Ct{NkxMkpdo?dY|jJ!U#C8k87F3nXPkF?|G56oTd=zpzhQk6 zhslRUAIaR3aQUqC3z>gZptE5qk;j>7 zW{p{f2o(*|fVMr)**FsLb4oszf1a2jgEtQqN3`S57VNIY4{+Z^KbclIT|O$FEgzr! zL_RN_XGknATiTh}tIw^K#pO#)^#L@8AH1AyW+f^W1n@51m1c#U`m$^#!I-kAZm&kL z-n@p#Az_0@4S>h7?-zbx2)LqMf3skBEp88VaFVHqUYBVnx=6T3f{pS1f-{Ru^`RRR zei~bzmPR`V>=}tDwLNI-)0+O>WNJSRthTMrL}gy-d_#gW0YCR-xXdh?B_9+{lSw&K zggo_#^?t#E-L<&-;7#wXgC#gKRNg)IzRWD1)k(r6wr)&0`=azq6K>`q4yQk~-fuO% zfrX^-zTKqyGDc=dr_}6_8ZF7`Iu{`qG;f2)^Y0veR{}GF#VcWumQk0`a4pzfi+dYq zl&5%Y86<;u_=s=Xa0$u@k++Y&BeP59nhn_)?|*XgQ!_SH=={UsL;w=&&TTLe*j1Zq zNYJ2=RbNI)wu#heNoFoWE|8d2GDqGz@(&r8JzjkF`N^Q|L&bBmH&y82z{LZ9X~FJV z+|lTp?Vj=8GAL!J4Bb0ShG&eFfUH0X%?*>+3*IzgwDm{=mg_i?bZMJuZV;eSJ=A~5 z0?$$w@3r6S(`aUpfat~6Y8@w1<;{Y>%WDO1$oSk)8J#s&hNX{?p?ijjck&Q%ZSpcd z{4K~m7ne?ahz}3lJ5#6D%V$$B6HN;W8xESGlWOei|ao9P`jrli;>a3E*S;qPLPR*Cd-t=ubS}UVF&;!Qkxq_*@eJ*?c9)H zK;u%&-Q0gGFji3ADA5pL78;uoWFqb^A2o_m-kf(6c*&8gIur$ zv+u=4E1@z`NYW@+xiBJgl+h(p!b>D71K6DhcZ{{&Z!_~gNepNR*wP|GJZDJ5$QMQu zi1OKmXTCB&O=Hf*iQhhdBil~xcY5Fau}LdxC`Igh^;#SckmQrx;RpW$8%TF0+%Dq(%4k$bj_Dw3JD7MXdN}^}}JN@~Dh0 z?YC>A4Ylxn`{I_EdP~{zN>nMOsFbuX5LQSK3CdiEMuUC*}w3wLDA;OvV)YpvM5-W*&uDFh}%Eph9pjzO0 zdIZTmHa*C6(@ZmI_GUVLHduB!OET&+C8I7=(rYs$y(U9u=FT#*1-vPnrtZ7y`>*%@ zg9Yp9mR6)0O5$NB36fe93pr7vDRoGSmamCqhh#^Go+P z<<2HTM1qwJjmXXmEJPn8H;5uVqQ`gmE)gYR(Y(9oah#t0ss-h{FSN%kv2NIIB7v8Z zHLEf1H zlY6#CqXCZ@A_{_XO9f$)#j6-eZySJl55mY0j|1FvSak|@{(JwA%+o9`TDhy#304nx z-8Ezs5XlZWND(1pJO;47hsFV|-)UvGVLW)!4*R1l4(Ga$8#6AQlvE>fpmG_Y`q!9N z6}4*Sc}8Lnrpj*{%zmoHy$x3%+#V><7XaJq&%mG8|NQFL??0uX + + + + + Copy Text Companion + + + +
+ + +
+
+
+
History
+
0 items
+
+
+
+
+ + +
+ + + + diff --git a/companion/go.mod b/companion/go.mod new file mode 100644 index 0000000..701f327 --- /dev/null +++ b/companion/go.mod @@ -0,0 +1,36 @@ +module copytextcompanion + +go 1.22.0 + +require github.com/wailsapp/wails/v2 v2.12.0 + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/companion/go.sum b/companion/go.sum new file mode 100644 index 0000000..b5aa9ee --- /dev/null +++ b/companion/go.sum @@ -0,0 +1,83 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/companion/hotkey_other.go b/companion/hotkey_other.go new file mode 100644 index 0000000..8450d92 --- /dev/null +++ b/companion/hotkey_other.go @@ -0,0 +1,19 @@ +//go:build !windows + +package main + +type hotkeyManager struct{} + +func newHotkeyManager(func()) *hotkeyManager { + return &hotkeyManager{} +} + +func validateHotkey(string) error { + return nil +} + +func (manager *hotkeyManager) Update(string) error { + return nil +} + +func (manager *hotkeyManager) Stop() {} diff --git a/companion/hotkey_windows.go b/companion/hotkey_windows.go new file mode 100644 index 0000000..fd4497c --- /dev/null +++ b/companion/hotkey_windows.go @@ -0,0 +1,191 @@ +//go:build windows + +package main + +import ( + "errors" + "fmt" + "runtime" + "strings" + "sync" + "syscall" + "time" + "unsafe" +) + +const ( + modAlt = 0x0001 + modControl = 0x0002 + modShift = 0x0004 + modNoRepeat = 0x4000 + wmHotkey = 0x0312 + wmQuit = 0x0012 + vkSpace = 0x20 +) + +type nativeMessage struct { + Hwnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Point struct { + X int32 + Y int32 + } +} + +type hotkeyManager struct { + mu sync.Mutex + callback func() + active *hotkeyRegistration +} + +type hotkeyRegistration struct { + stop chan struct{} + done chan struct{} + threadID uint32 +} + +var ( + user32Proc = syscall.NewLazyDLL("user32.dll") + registerHotKeyProc = user32Proc.NewProc("RegisterHotKey") + unregisterHotKeyProc = user32Proc.NewProc("UnregisterHotKey") + getMessageProc = user32Proc.NewProc("GetMessageW") + postThreadMessageProc = user32Proc.NewProc("PostThreadMessageW") + kernel32HotkeyProc = syscall.NewLazyDLL("kernel32.dll") + getCurrentThreadIDProc = kernel32HotkeyProc.NewProc("GetCurrentThreadId") +) + +func newHotkeyManager(callback func()) *hotkeyManager { + return &hotkeyManager{callback: callback} +} + +func validateHotkey(value string) error { + _, _, ok := parseWindowsHotkey(value) + if !ok { + return fmt.Errorf("unsupported hotkey: %s", strings.TrimSpace(value)) + } + return nil +} + +func (manager *hotkeyManager) Update(value string) error { + modifiers, key, ok := parseWindowsHotkey(value) + if !ok { + return fmt.Errorf("unsupported hotkey: %s", strings.TrimSpace(value)) + } + + manager.mu.Lock() + defer manager.mu.Unlock() + + manager.stopLocked() + registration := &hotkeyRegistration{ + stop: make(chan struct{}), + done: make(chan struct{}), + } + registered := make(chan error, 1) + go runWindowsHotkey(modifiers, key, manager.callback, registration, registered) + if err := <-registered; err != nil { + return err + } + manager.active = registration + return nil +} + +func (manager *hotkeyManager) Stop() { + manager.mu.Lock() + defer manager.mu.Unlock() + manager.stopLocked() +} + +func (manager *hotkeyManager) stopLocked() { + if manager.active == nil { + return + } + registration := manager.active + manager.active = nil + close(registration.stop) + if registration.threadID != 0 { + postThreadMessageProc.Call(uintptr(registration.threadID), wmQuit, 0, 0) + } + select { + case <-registration.done: + case <-time.After(1500 * time.Millisecond): + } +} + +func parseWindowsHotkey(value string) (uintptr, uintptr, bool) { + parts := strings.Split(value, "+") + var modifiers uintptr = modNoRepeat + var key uintptr + keyCount := 0 + + for _, part := range parts { + token := strings.ToLower(strings.TrimSpace(part)) + switch token { + case "ctrl", "control": + modifiers |= modControl + case "shift": + modifiers |= modShift + case "alt", "option": + modifiers |= modAlt + case "space": + key = vkSpace + keyCount++ + default: + if len(token) == 1 { + char := token[0] + if char >= 'a' && char <= 'z' { + key = uintptr(char - 32) + keyCount++ + } else if char >= '0' && char <= '9' { + key = uintptr(char) + keyCount++ + } else { + return 0, 0, false + } + } else { + return 0, 0, false + } + } + } + + return modifiers, key, key != 0 && keyCount == 1 && modifiers != modNoRepeat +} + +func runWindowsHotkey(modifiers uintptr, key uintptr, callback func(), registration *hotkeyRegistration, registered chan<- error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + defer close(registration.done) + + threadID, _, _ := getCurrentThreadIDProc.Call() + registration.threadID = uint32(threadID) + + const hotkeyID = 1 + ok, _, callErr := registerHotKeyProc.Call(0, hotkeyID, modifiers, key) + if ok == 0 { + if callErr == syscall.Errno(0) { + callErr = errors.New("RegisterHotKey failed") + } + registered <- callErr + return + } + defer unregisterHotKeyProc.Call(0, hotkeyID) + registered <- nil + + var message nativeMessage + for { + select { + case <-registration.stop: + return + default: + } + result, _, _ := getMessageProc.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0) + if result == 0 || int32(result) == -1 { + return + } + if message.Message == wmHotkey && callback != nil { + callback() + } + } +} diff --git a/companion/hotkey_windows_test.go b/companion/hotkey_windows_test.go new file mode 100644 index 0000000..0ae9d72 --- /dev/null +++ b/companion/hotkey_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package main + +import "testing" + +func TestParseWindowsHotkeyAcceptsDefault(t *testing.T) { + modifiers, key, ok := parseWindowsHotkey("Ctrl+Shift+Space") + if !ok { + t.Fatal("expected default hotkey to parse") + } + if modifiers == 0 || key == 0 { + t.Fatalf("expected modifiers and key, got modifiers=%d key=%d", modifiers, key) + } +} + +func TestValidateHotkeyRejectsUnsupportedValues(t *testing.T) { + for _, value := range []string{"", "Ctrl", "Ctrl+Alt", "Ctrl+Shift+Mouse1"} { + if err := validateHotkey(value); err == nil { + t.Fatalf("expected %q to be rejected", value) + } + } +} diff --git a/companion/internal/companion/filelock_other.go b/companion/internal/companion/filelock_other.go new file mode 100644 index 0000000..4869bf0 --- /dev/null +++ b/companion/internal/companion/filelock_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package companion + +import "os" + +func withFileLock(_ string, fn func() error) error { + return fn() +} + +func replaceFileAtomic(source string, target string) error { + return os.Rename(source, target) +} diff --git a/companion/internal/companion/filelock_windows.go b/companion/internal/companion/filelock_windows.go new file mode 100644 index 0000000..ae19a1a --- /dev/null +++ b/companion/internal/companion/filelock_windows.go @@ -0,0 +1,77 @@ +//go:build windows + +package companion + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + "unsafe" +) + +const ( + lockfileExclusiveLock = 0x00000002 + movefileReplaceExisting = 0x00000001 + movefileWriteThrough = 0x00000008 +) + +var ( + kernel32Proc = syscall.NewLazyDLL("kernel32.dll") + lockFileExProc = kernel32Proc.NewProc("LockFileEx") + unlockFileExProc = kernel32Proc.NewProc("UnlockFileEx") + moveFileExProc = kernel32Proc.NewProc("MoveFileExW") +) + +func withFileLock(lockPath string, fn func() error) error { + if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil { + return err + } + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer file.Close() + + var overlapped syscall.Overlapped + locked, _, callErr := lockFileExProc.Call( + file.Fd(), + lockfileExclusiveLock, + 0, + 1, + 0, + uintptr(unsafe.Pointer(&overlapped)), + ) + if locked == 0 { + return fmt.Errorf("lock store file: %w", callErr) + } + defer unlockFileExProc.Call( + file.Fd(), + 0, + 1, + 0, + uintptr(unsafe.Pointer(&overlapped)), + ) + + return fn() +} + +func replaceFileAtomic(source string, target string) error { + sourcePtr, err := syscall.UTF16PtrFromString(source) + if err != nil { + return err + } + targetPtr, err := syscall.UTF16PtrFromString(target) + if err != nil { + return err + } + replaced, _, callErr := moveFileExProc.Call( + uintptr(unsafe.Pointer(sourcePtr)), + uintptr(unsafe.Pointer(targetPtr)), + movefileReplaceExisting|movefileWriteThrough, + ) + if replaced == 0 { + return fmt.Errorf("replace store file: %w", callErr) + } + return nil +} diff --git a/companion/internal/companion/paths.go b/companion/internal/companion/paths.go new file mode 100644 index 0000000..8554781 --- /dev/null +++ b/companion/internal/companion/paths.go @@ -0,0 +1,24 @@ +package companion + +import ( + "os" + "path/filepath" +) + +type Paths struct { + DataDir string + StorePath string +} + +func DefaultPaths() Paths { + root := os.Getenv("APPDATA") + if root == "" { + root = os.TempDir() + } + + dataDir := filepath.Join(root, "CopyTextWithoutSelecting") + return Paths{ + DataDir: dataDir, + StorePath: filepath.Join(dataDir, "history.json"), + } +} diff --git a/companion/internal/companion/protocol.go b/companion/internal/companion/protocol.go new file mode 100644 index 0000000..c9d84a8 --- /dev/null +++ b/companion/internal/companion/protocol.go @@ -0,0 +1,298 @@ +package companion + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" +) + +func RunNativeMessaging(input io.Reader, output io.Writer, service *Service) error { + reader := bufio.NewReader(input) + for { + message, err := ReadNativeMessage(reader) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + + resp := HandleEnvelope(message, service) + if err := WriteNativeMessage(output, resp); err != nil { + return err + } + } +} + +func ReadNativeMessage(reader io.Reader) (Envelope, error) { + var length uint32 + if err := binary.Read(reader, binary.LittleEndian, &length); err != nil { + return Envelope{}, err + } + if length == 0 || length > MaxNativeRequestBytes { + return Envelope{}, fmt.Errorf("invalid native message length: %d", length) + } + + buffer := make([]byte, length) + if _, err := io.ReadFull(reader, buffer); err != nil { + return Envelope{}, err + } + + var message Envelope + if err := json.Unmarshal(buffer, &message); err != nil { + return Envelope{}, err + } + return message, nil +} + +func WriteNativeMessage(writer io.Writer, value Response) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + if len(payload) > MaxNativeResponseBytes { + return fmt.Errorf("native response too large: %d bytes", len(payload)) + } + + if err := binary.Write(writer, binary.LittleEndian, uint32(len(payload))); err != nil { + return err + } + _, err = io.Copy(writer, bytes.NewReader(payload)) + return err +} + +func HandleEnvelope(message Envelope, service *Service) Response { + if message.Version != ProtocolVersion { + return Fail(message.ID, "UNSUPPORTED_VERSION", "Unsupported protocol version.", false) + } + + switch message.Type { + case "PING": + var payload struct { + ExtensionVersion string `json:"extensionVersion"` + } + if err := decodePayload(message.Payload, &payload); err != nil { + return Fail(message.ID, "BAD_PAYLOAD", err.Error(), false) + } + return OK(message.ID, service.Ping(payload.ExtensionVersion)) + case "CLIPBOARD_EVENT": + return handleClipboardEvent(message, service) + case "HISTORY_LIST": + return handleHistoryList(message, service) + case "HISTORY_DELETE": + return handleHistoryDelete(message, service) + case "HISTORY_CLEAR": + return handleHistoryClear(message, service) + case "HISTORY_PIN": + return handleHistoryPin(message, service) + case "FORMAT_PREVIEW": + return handleFormatPreview(message, service) + case "SETTINGS_GET": + return handleSettingsGet(message, service) + case "SETTINGS_UPDATE": + return handleSettingsUpdate(message, service) + case "OPEN_APP": + return OK(message.ID, map[string]any{"opened": service.OpenApp()}) + case "PRIVACY_GET": + return handlePrivacyGet(message, service) + default: + return Fail(message.ID, "UNKNOWN_MESSAGE", "Unknown message type.", false) + } +} + +func handleClipboardEvent(message Envelope, service *Service) Response { + var event ClipboardEvent + if err := json.Unmarshal(message.Payload, &event); err != nil { + return Fail(message.ID, "BAD_PAYLOAD", err.Error(), false) + } + + result, err := service.RecordClipboardEvent(event) + if err != nil { + return Fail(message.ID, "STORE_WRITE_FAILED", err.Error(), true) + } + return OK(message.ID, result) +} + +func handleHistoryList(message Envelope, service *Service) Response { + var options ListHistoryOptions + if err := decodePayload(message.Payload, &options); err != nil { + return Fail(message.ID, "BAD_PAYLOAD", err.Error(), false) + } + + history, err := service.ListHistory(options) + if err != nil { + return Fail(message.ID, "STORE_READ_FAILED", err.Error(), true) + } + return historyListResponse(message.ID, history) +} + +func handleHistoryDelete(message Envelope, service *Service) Response { + var payload struct { + ID string `json:"id"` + Text string `json:"text"` + OperationAt int64 `json:"operationAt"` + } + if err := json.Unmarshal(message.Payload, &payload); err != nil { + return Fail(message.ID, "BAD_PAYLOAD", err.Error(), false) + } + if payload.ID == "" && payload.Text == "" { + return Fail(message.ID, "BAD_PAYLOAD", "history id or text is required.", false) + } + deleted, err := service.DeleteHistoryResolvedAt(payload.ID, payload.Text, payload.OperationAt) + if err != nil { + return Fail(message.ID, "STORE_WRITE_FAILED", err.Error(), true) + } + return OK(message.ID, map[string]any{"deleted": deleted}) +} + +func handleHistoryClear(message Envelope, service *Service) Response { + var payload struct { + OperationAt int64 `json:"operationAt"` + } + if err := decodePayload(message.Payload, &payload); err != nil { + return Fail(message.ID, "BAD_PAYLOAD", err.Error(), false) + } + cleared, err := service.ClearHistoryAt(payload.OperationAt) + if err != nil { + return Fail(message.ID, "STORE_WRITE_FAILED", err.Error(), true) + } + return OK(message.ID, map[string]any{"cleared": cleared}) +} + +func handleHistoryPin(message Envelope, service *Service) Response { + var payload struct { + ID string `json:"id"` + Text string `json:"text"` + Pinned bool `json:"pinned"` + OperationAt int64 `json:"operationAt"` + } + if err := json.Unmarshal(message.Payload, &payload); err != nil { + return Fail(message.ID, "BAD_PAYLOAD", err.Error(), false) + } + if payload.ID == "" && payload.Text == "" { + return Fail(message.ID, "BAD_PAYLOAD", "history id or text is required.", false) + } + changed, err := service.PinHistoryResolvedAt(payload.ID, payload.Text, payload.Pinned, payload.OperationAt) + if err != nil { + return Fail(message.ID, "STORE_WRITE_FAILED", err.Error(), true) + } + return OK(message.ID, map[string]any{"changed": changed}) +} + +func handleFormatPreview(message Envelope, service *Service) Response { + var payload struct { + Text string `json:"text"` + Action string `json:"action"` + } + if err := json.Unmarshal(message.Payload, &payload); err != nil { + return Fail(message.ID, "BAD_PAYLOAD", err.Error(), false) + } + return OK(message.ID, service.FormatPreview(payload.Text, payload.Action)) +} + +func handleSettingsGet(message Envelope, service *Service) Response { + settings, err := service.GetSettings() + if err != nil { + return Fail(message.ID, "STORE_READ_FAILED", err.Error(), true) + } + return OK(message.ID, map[string]any{"settings": settings}) +} + +func handleSettingsUpdate(message Envelope, service *Service) Response { + var patch SettingsPatch + if err := json.Unmarshal(message.Payload, &patch); err != nil { + return Fail(message.ID, "BAD_PAYLOAD", err.Error(), false) + } + settings, err := service.UpdateSettings(patch) + if err != nil { + return Fail(message.ID, "STORE_WRITE_FAILED", err.Error(), true) + } + return OK(message.ID, map[string]any{"settings": settings}) +} + +func handlePrivacyGet(message Envelope, service *Service) Response { + settings, err := service.GetSettings() + if err != nil { + return Fail(message.ID, "STORE_READ_FAILED", err.Error(), true) + } + return OK(message.ID, map[string]any{ + "persistentHistory": settings.HistoryEnabled, + "pauseCapture": !settings.HistoryEnabled, + "storagePath": service.StorePath(), + "network": false, + }) +} + +func OK(id string, payload any) Response { + return Response{ID: id, OK: true, Payload: payload} +} + +func Fail(id string, code string, message string, recoverable bool) Response { + return Response{ + ID: id, + OK: false, + Error: &ErrorShape{ + Code: code, + Message: message, + Recoverable: recoverable, + }, + } +} + +func decodePayload(payload json.RawMessage, target any) error { + if len(payload) == 0 || string(payload) == "null" { + return nil + } + return json.Unmarshal(payload, target) +} + +func historyListResponse(id string, history []HistoryEntry) Response { + total := len(history) + hasMore := false + history = nativeResponseHistory(history) + for { + response := OK(id, map[string]any{ + "history": history, + "total": total, + "hasMore": hasMore, + }) + payload, err := json.Marshal(response) + if err == nil && len(payload) <= MaxNativeResponseBytes { + return response + } + if len(history) == 0 { + return Fail(id, "RESPONSE_TOO_LARGE", "History response is too large.", true) + } + history = history[:len(history)-1] + hasMore = true + } +} + +func nativeResponseHistory(history []HistoryEntry) []HistoryEntry { + entries := make([]HistoryEntry, 0, len(history)) + for _, entry := range history { + entries = append(entries, nativeResponseEntry(entry)) + } + return entries +} + +func nativeResponseEntry(entry HistoryEntry) HistoryEntry { + next := entry + if text, truncated := truncateStringBytes(next.Text, MaxNativeResponseTextBytes); truncated { + next.Text = text + next.Truncated = true + } + next.Snippet, _ = truncateStringBytes(next.Snippet, 512) + next.ID, _ = truncateStringBytes(next.ID, 256) + next.Source, _ = truncateStringBytes(next.Source, 128) + next.Mode, _ = truncateStringBytes(next.Mode, 64) + next.Format, _ = truncateStringBytes(next.Format, 64) + next.URL, _ = truncateStringBytes(next.URL, 2048) + next.Hostname, _ = truncateStringBytes(next.Hostname, 255) + next.Title, _ = truncateStringBytes(next.Title, 512) + return next +} diff --git a/companion/internal/companion/protocol_test.go b/companion/internal/companion/protocol_test.go new file mode 100644 index 0000000..b31d532 --- /dev/null +++ b/companion/internal/companion/protocol_test.go @@ -0,0 +1,275 @@ +package companion + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "io" + "strings" + "testing" +) + +func TestReadNativeMessage(t *testing.T) { + payload, _ := json.Marshal(Envelope{ + ID: "abc", + Version: ProtocolVersion, + Type: "PING", + }) + var buffer bytes.Buffer + if err := binary.Write(&buffer, binary.LittleEndian, uint32(len(payload))); err != nil { + t.Fatal(err) + } + buffer.Write(payload) + + message, err := ReadNativeMessage(&buffer) + if err != nil { + t.Fatal(err) + } + if message.Type != "PING" || message.ID != "abc" { + t.Fatalf("unexpected envelope: %#v", message) + } +} + +func TestWriteNativeMessage(t *testing.T) { + input := Response{ID: "abc", OK: true, Payload: map[string]any{"pong": true}} + var buffer bytes.Buffer + if err := WriteNativeMessage(&buffer, input); err != nil { + t.Fatal(err) + } + + var length uint32 + if err := binary.Read(&buffer, binary.LittleEndian, &length); err != nil { + t.Fatal(err) + } + if length == 0 { + t.Fatal("expected response length") + } +} + +func TestHandleEnvelopeSettingsUpdate(t *testing.T) { + service := newTestService(t) + maxItems := 42 + payload, _ := json.Marshal(SettingsPatch{MaxItems: &maxItems}) + response := HandleEnvelope(Envelope{ + ID: "settings-1", + Version: ProtocolVersion, + Type: "SETTINGS_UPDATE", + Payload: payload, + }, service) + if !response.OK { + t.Fatalf("expected settings update ok, got %#v", response) + } +} + +func TestHandleEnvelopeRejectsMalformedPayloads(t *testing.T) { + service := newTestService(t) + cases := []Envelope{ + {ID: "ping", Version: ProtocolVersion, Type: "PING", Payload: json.RawMessage("{")}, + {ID: "list", Version: ProtocolVersion, Type: "HISTORY_LIST", Payload: json.RawMessage("{")}, + {ID: "delete", Version: ProtocolVersion, Type: "HISTORY_DELETE", Payload: json.RawMessage("{}")}, + {ID: "pin", Version: ProtocolVersion, Type: "HISTORY_PIN", Payload: json.RawMessage(`{"pinned":true}`)}, + } + + for _, item := range cases { + response := HandleEnvelope(item, service) + if response.OK || response.Error == nil || response.Error.Code != "BAD_PAYLOAD" { + t.Fatalf("expected BAD_PAYLOAD for %s, got %#v", item.Type, response) + } + } +} + +func TestHandleEnvelopeResolvesHistoryMutationByText(t *testing.T) { + service := newTestService(t) + record, err := service.RecordClipboardEvent(ClipboardEvent{Text: "protocol identity"}) + if err != nil { + t.Fatal(err) + } + + pinPayload, _ := json.Marshal(map[string]any{ + "id": "extension-local-id", + "text": "protocol identity", + "pinned": true, + }) + pinned := HandleEnvelope(Envelope{ + ID: "pin-by-text", + Version: ProtocolVersion, + Type: "HISTORY_PIN", + Payload: pinPayload, + }, service) + if !pinned.OK { + t.Fatalf("expected pin by text fallback, got %#v", pinned) + } + + deletePayload, _ := json.Marshal(map[string]any{ + "id": "extension-local-id", + "text": "protocol identity", + }) + deleted := HandleEnvelope(Envelope{ + ID: "delete-by-text", + Version: ProtocolVersion, + Type: "HISTORY_DELETE", + Payload: deletePayload, + }, service) + if !deleted.OK { + t.Fatalf("expected delete by text fallback, got %#v", deleted) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 0 { + t.Fatalf("expected entry %q to be deleted, got %#v", record.ID, history) + } +} + +func TestHandleEnvelopeHonorsHistoryOperationAt(t *testing.T) { + service := newTestService(t) + record, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "protocol conflict", + CreatedAt: 100, + OperationAt: 200, + }) + if err != nil { + t.Fatal(err) + } + + deletePayload, _ := json.Marshal(map[string]any{ + "id": record.ID, + "text": "protocol conflict", + "operationAt": 150, + }) + deleted := HandleEnvelope(Envelope{ + ID: "stale-delete", + Version: ProtocolVersion, + Type: "HISTORY_DELETE", + Payload: deletePayload, + }, service) + if !deleted.OK { + t.Fatalf("expected ok stale delete response, got %#v", deleted) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 || history[0].ID != record.ID { + t.Fatalf("expected stale protocol delete to preserve entry, got %#v", history) + } +} + +func TestHistoryListResponseFitsNativeMessageLimit(t *testing.T) { + history := make([]HistoryEntry, 0, 8) + for index := 0; index < 8; index++ { + history = append(history, HistoryEntry{ + ID: string(rune('a' + index)), + Text: strings.Repeat("x", MaxNativeResponseBytes/3), + Snippet: "large", + CreatedAt: int64(index + 1), + }) + } + + response := historyListResponse("large", history) + if !response.OK { + t.Fatalf("expected trimmed ok response, got %#v", response) + } + payload, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if len(payload) > MaxNativeResponseBytes { + t.Fatalf("response exceeded native response limit: %d", len(payload)) + } + shape, ok := response.Payload.(map[string]any) + if !ok { + t.Fatalf("unexpected payload shape: %#v", response.Payload) + } + if shape["hasMore"] != true || shape["total"] != len(history) { + t.Fatalf("expected hasMore=true with original total, got %#v", shape) + } +} + +func TestHistoryListResponseTruncatesOversizedEntry(t *testing.T) { + history := []HistoryEntry{{ + ID: "oversized", + Text: strings.Repeat("x", MaxNativeResponseBytes+1024), + Snippet: strings.Repeat("s", 2048), + Title: strings.Repeat("t", 4096), + CreatedAt: 1, + }} + + response := historyListResponse("oversized", history) + if !response.OK { + t.Fatalf("expected ok response, got %#v", response) + } + payload, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if len(payload) > MaxNativeResponseBytes { + t.Fatalf("response exceeded native response limit: %d", len(payload)) + } + shape, ok := response.Payload.(map[string]any) + if !ok { + t.Fatalf("unexpected payload shape: %#v", response.Payload) + } + entries, ok := shape["history"].([]HistoryEntry) + if !ok || len(entries) != 1 { + t.Fatalf("unexpected history shape: %#v", shape["history"]) + } + if !entries[0].Truncated || len(entries[0].Text) > MaxNativeResponseTextBytes { + t.Fatalf("expected oversized entry to be marked truncated, got %#v", entries[0]) + } +} + +func TestHistoryListResponseKeepsQuoteHeavyEntryUnderLimit(t *testing.T) { + history := []HistoryEntry{{ + ID: "quoted", + Text: strings.Repeat(`"`, MaxStoredTextBytes), + Snippet: "quoted", + CreatedAt: 1, + }} + + response := historyListResponse("quoted", history) + if !response.OK { + t.Fatalf("expected ok response, got %#v", response) + } + payload, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + if len(payload) > MaxNativeResponseBytes { + t.Fatalf("response exceeded native response limit: %d", len(payload)) + } + shape, ok := response.Payload.(map[string]any) + if !ok { + t.Fatalf("unexpected payload shape: %#v", response.Payload) + } + entries, ok := shape["history"].([]HistoryEntry) + if !ok || len(entries) != 1 { + t.Fatalf("expected one bounded history entry, got %#v", shape["history"]) + } + if !entries[0].Truncated || len(entries[0].Text) > MaxNativeResponseTextBytes { + t.Fatalf("expected quote-heavy entry to be response-truncated, got %#v", entries[0]) + } +} + +type shortWriter struct { + writes int +} + +func (writer *shortWriter) Write(payload []byte) (int, error) { + writer.writes++ + if writer.writes == 1 { + return len(payload), nil + } + return 0, nil +} + +func TestWriteNativeMessageDetectsShortPayloadWrite(t *testing.T) { + err := WriteNativeMessage(&shortWriter{}, Response{ID: "short", OK: true}) + if !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("expected short write error, got %v", err) + } +} diff --git a/companion/internal/companion/service.go b/companion/internal/companion/service.go new file mode 100644 index 0000000..0f4ff7f --- /dev/null +++ b/companion/internal/companion/service.go @@ -0,0 +1,185 @@ +package companion + +import ( + "os" + "os/exec" + "strings" + "sync" + "time" +) + +type Service struct { + store *Store + + openMu sync.Mutex + openApp func() bool +} + +func NewService(paths Paths) *Service { + return &Service{ + store: NewStore(paths.StorePath), + } +} + +func (service *Service) StorePath() string { + return service.store.Path() +} + +func (service *Service) SetOpenAppHandler(handler func() bool) { + service.openMu.Lock() + defer service.openMu.Unlock() + service.openApp = handler +} + +func (service *Service) Ping(extensionVersion string) map[string]any { + return map[string]any{ + "version": ProtocolVersion, + "host": "copy-text-companion", + "extensionVersion": extensionVersion, + "capabilities": []string{ + "clipboard-event", + "history-list", + "history-delete", + "history-clear", + "history-pin", + "format-preview", + "settings", + "open-app", + "privacy", + }, + "persistence": "local-json", + "maxPayload": MaxNativeRequestBytes, + "maxRequestBytes": MaxNativeRequestBytes, + "maxResponseBytes": MaxNativeResponseBytes, + "maxStoredTextBytes": MaxStoredTextBytes, + } +} + +func (service *Service) RecordClipboardEvent(event ClipboardEvent) (RecordResult, error) { + text := event.Text + if strings.TrimSpace(text) == "" { + return RecordResult{Stored: false, Reason: "empty"}, nil + } + entryID := HistoryID(text) + storedText, truncated := truncateStringBytes(text, MaxStoredTextBytes) + + settings, err := service.store.GetSettings() + if err != nil { + return RecordResult{}, err + } + if !settings.HistoryEnabled { + return RecordResult{Stored: false, Reason: "history-disabled"}, nil + } + + if event.CreatedAt == 0 { + event.CreatedAt = time.Now().UnixMilli() + } + if event.OperationAt == 0 { + event.OperationAt = event.CreatedAt + } + if event.Format == "" { + event.Format = DetectFormat(storedText) + } + if event.Snippet == "" { + event.Snippet = Snippet(storedText, 90) + } + if event.Source == "" { + event.Source = "web-extension" + } + if event.Mode == "" { + event.Mode = "copy" + } + + entry, err := service.store.AddHistoryEntry(HistoryEntry{ + ID: entryID, + Text: storedText, + Snippet: event.Snippet, + Format: event.Format, + Source: event.Source, + Mode: event.Mode, + URL: event.URL, + Hostname: event.Hostname, + Title: event.Title, + CreatedAt: event.CreatedAt, + UpdatedAt: event.OperationAt, + SelectionBased: event.SelectionBased, + Truncated: event.Truncated || truncated, + }, settings.MaxItems) + if err != nil { + return RecordResult{}, err + } + return RecordResult{Stored: true, ID: entry.ID, Truncated: entry.Truncated}, nil +} + +func (service *Service) ListHistory(options ListHistoryOptions) ([]HistoryEntry, error) { + return service.store.ListHistory(options) +} + +func (service *Service) DeleteHistory(id string) (bool, error) { + return service.store.DeleteHistoryAt(id, time.Now().UnixMilli()) +} + +func (service *Service) DeleteHistoryResolved(id string, text string) (bool, error) { + return service.store.DeleteHistoryResolvedAt(id, text, time.Now().UnixMilli()) +} + +func (service *Service) DeleteHistoryResolvedAt(id string, text string, operationAt int64) (bool, error) { + return service.store.DeleteHistoryResolvedAt(id, text, operationAt) +} + +func (service *Service) ClearHistory() (int, error) { + return service.store.ClearHistoryAt(time.Now().UnixMilli()) +} + +func (service *Service) ClearHistoryAt(operationAt int64) (int, error) { + return service.store.ClearHistoryAt(operationAt) +} + +func (service *Service) PinHistory(id string, pinned bool) (bool, error) { + return service.store.PinHistoryAt(id, pinned, time.Now().UnixMilli()) +} + +func (service *Service) PinHistoryResolved(id string, text string, pinned bool) (bool, error) { + return service.store.PinHistoryResolvedAt(id, text, pinned, time.Now().UnixMilli()) +} + +func (service *Service) PinHistoryResolvedAt(id string, text string, pinned bool, operationAt int64) (bool, error) { + return service.store.PinHistoryResolvedAt(id, text, pinned, operationAt) +} + +func (service *Service) FormatPreview(text string, action string) FormatPreviewResult { + format := DetectFormat(text) + output := text + if action != "" { + output = ApplyAction(text, action) + } + output, truncated := truncateStringBytes(output, MaxPreviewTextBytes) + return FormatPreviewResult{ + Format: format, + Text: output, + Actions: ActionsForFormat(format), + Truncated: truncated, + } +} + +func (service *Service) GetSettings() (Settings, error) { + return service.store.GetSettings() +} + +func (service *Service) UpdateSettings(patch SettingsPatch) (Settings, error) { + return service.store.UpdateSettings(patch) +} + +func (service *Service) OpenApp() bool { + service.openMu.Lock() + handler := service.openApp + service.openMu.Unlock() + if handler == nil { + executable, err := os.Executable() + if err != nil { + return false + } + return exec.Command(executable).Start() == nil + } + return handler() +} diff --git a/companion/internal/companion/service_test.go b/companion/internal/companion/service_test.go new file mode 100644 index 0000000..6eb60ee --- /dev/null +++ b/companion/internal/companion/service_test.go @@ -0,0 +1,596 @@ +package companion + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "sync" + "testing" +) + +func newTestService(t *testing.T) *Service { + t.Helper() + return NewService(Paths{ + DataDir: t.TempDir(), + StorePath: filepath.Join(t.TempDir(), "history.json"), + }) +} + +func TestRecordListPinDeleteHistory(t *testing.T) { + service := newTestService(t) + + result, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "{\"ok\":true}", + Source: "web-extension", + Hostname: "example.com", + }) + if err != nil { + t.Fatal(err) + } + if !result.Stored || result.ID == "" { + t.Fatalf("expected stored result with id, got %#v", result) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 || history[0].Format != "json" { + t.Fatalf("expected one json history entry, got %#v", history) + } + + changed, err := service.PinHistory(result.ID, true) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected pin to change entry") + } + + deleted, err := service.DeleteHistory(result.ID) + if err != nil { + t.Fatal(err) + } + if !deleted { + t.Fatal("expected delete to remove entry") + } +} + +func TestRecordClipboardEventPreservesWhitespaceText(t *testing.T) { + service := newTestService(t) + text := " {\"ok\":true}\n" + result, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: text, + Source: "desktop-clipboard", + }) + if err != nil { + t.Fatal(err) + } + if !result.Stored { + t.Fatalf("expected stored result, got %#v", result) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 { + t.Fatalf("expected one history entry, got %#v", history) + } + if history[0].Text != text { + t.Fatalf("expected exact text to be preserved, got %q", history[0].Text) + } + if history[0].Format != "json" { + t.Fatalf("expected whitespace-wrapped JSON to be detected, got %#v", history[0]) + } +} + +func TestSettingsPatchDisablesHistory(t *testing.T) { + service := newTestService(t) + disabled := false + maxItems := 3 + settings, err := service.UpdateSettings(SettingsPatch{ + HistoryEnabled: &disabled, + MaxItems: &maxItems, + }) + if err != nil { + t.Fatal(err) + } + if settings.HistoryEnabled || settings.MaxItems != 3 { + t.Fatalf("unexpected settings: %#v", settings) + } + + result, err := service.RecordClipboardEvent(ClipboardEvent{Text: "ignored"}) + if err != nil { + t.Fatal(err) + } + if result.Stored || result.Reason != "history-disabled" { + t.Fatalf("expected disabled history result, got %#v", result) + } +} + +func TestHandleEnvelopeRoutesHistoryPin(t *testing.T) { + service := newTestService(t) + record, err := service.RecordClipboardEvent(ClipboardEvent{Text: "pin me"}) + if err != nil { + t.Fatal(err) + } + + payload, _ := json.Marshal(map[string]any{"id": record.ID, "pinned": true}) + response := HandleEnvelope(Envelope{ + ID: "1", + Version: ProtocolVersion, + Type: "HISTORY_PIN", + Payload: payload, + }, service) + if !response.OK { + t.Fatalf("expected ok response: %#v", response) + } +} + +func TestAddHistoryReturnsUpdatedPinnedEntry(t *testing.T) { + service := newTestService(t) + first, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "shared text", + Source: "web-extension", + Hostname: "example.com", + CreatedAt: 100, + }) + if err != nil { + t.Fatal(err) + } + if changed, err := service.PinHistory(first.ID, true); err != nil || !changed { + t.Fatalf("expected pin to change entry, changed=%v err=%v", changed, err) + } + + second, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "shared text", + Source: "desktop-clipboard", + CreatedAt: 200, + }) + if err != nil { + t.Fatal(err) + } + if second.ID != first.ID { + t.Fatalf("expected deduped id %q, got %q", first.ID, second.ID) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 { + t.Fatalf("expected one history item, got %#v", history) + } + if !history[0].Pinned { + t.Fatalf("expected pinned status to survive dedup: %#v", history[0]) + } + if history[0].Hostname != "example.com" || history[0].Source != "web-extension" { + t.Fatalf("expected richer web metadata to survive desktop merge: %#v", history[0]) + } +} + +func TestRecentCanonicalCopyFromWebAndClipboardDeduplicates(t *testing.T) { + service := newTestService(t) + web, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "11 THG 6, 2026 Yêu cầu hoàn tiền (1 mục)", + Source: "web-extension", + Hostname: "reportaproblem.apple.com", + CreatedAt: 1000, + OperationAt: 1000, + }) + if err != nil { + t.Fatal(err) + } + desktop, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "11 thg 6, 2026 Yêu cầu hoàn tiền (1 mục)", + Source: "desktop-clipboard", + CreatedAt: 2200, + OperationAt: 2200, + }) + if err != nil { + t.Fatal(err) + } + if desktop.ID != web.ID { + t.Fatalf("expected recent canonical duplicate to reuse id %q, got %q", web.ID, desktop.ID) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 { + t.Fatalf("expected one deduped history item, got %#v", history) + } + if history[0].Hostname != "reportaproblem.apple.com" { + t.Fatalf("expected web metadata to survive desktop watcher merge, got %#v", history[0]) + } + + deleted, err := service.DeleteHistoryResolved("extension-local-id", "11 thg 6, 2026 yêu cầu hoàn tiền (1 mục)") + if err != nil { + t.Fatal(err) + } + if !deleted { + t.Fatal("expected canonical text fallback to resolve deduped item") + } +} + +func TestCanonicalCopyDedupKeepsDistantCopiesSeparate(t *testing.T) { + service := newTestService(t) + if _, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "Case Sensitive Label", + Source: "web-extension", + CreatedAt: 1000, + OperationAt: 1000, + }); err != nil { + t.Fatal(err) + } + if _, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "case sensitive label", + Source: "desktop-clipboard", + CreatedAt: 60000, + OperationAt: 60000, + }); err != nil { + t.Fatal(err) + } + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 2 { + t.Fatalf("expected distant canonical copies to remain separate, got %#v", history) + } +} + +func TestPinnedHistoryExemptFromMaxItems(t *testing.T) { + service := newTestService(t) + maxItems := 1 + if _, err := service.UpdateSettings(SettingsPatch{MaxItems: &maxItems}); err != nil { + t.Fatal(err) + } + + pinned, err := service.RecordClipboardEvent(ClipboardEvent{Text: "pinned", CreatedAt: 100}) + if err != nil { + t.Fatal(err) + } + if changed, err := service.PinHistory(pinned.ID, true); err != nil || !changed { + t.Fatalf("expected pin to change entry, changed=%v err=%v", changed, err) + } + if _, err := service.RecordClipboardEvent(ClipboardEvent{Text: "old unpinned", CreatedAt: 200}); err != nil { + t.Fatal(err) + } + if _, err := service.RecordClipboardEvent(ClipboardEvent{Text: "new unpinned", CreatedAt: 300}); err != nil { + t.Fatal(err) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 2 { + t.Fatalf("expected pinned plus latest unpinned item, got %#v", history) + } + if !history[0].Pinned || history[0].Text != "pinned" { + t.Fatalf("expected pinned item to remain first, got %#v", history) + } + if history[1].Text != "new unpinned" { + t.Fatalf("expected latest unpinned item to remain, got %#v", history[1]) + } +} + +func TestPinHistoryNoopDoesNotReportChanged(t *testing.T) { + service := newTestService(t) + record, err := service.RecordClipboardEvent(ClipboardEvent{Text: "pin noop"}) + if err != nil { + t.Fatal(err) + } + if changed, err := service.PinHistory(record.ID, true); err != nil || !changed { + t.Fatalf("expected first pin to change, changed=%v err=%v", changed, err) + } + if changed, err := service.PinHistory(record.ID, true); err != nil || changed { + t.Fatalf("expected duplicate pin to be no-op, changed=%v err=%v", changed, err) + } +} + +func TestHistoryMutationsResolveExtensionLocalIDByText(t *testing.T) { + service := newTestService(t) + record, err := service.RecordClipboardEvent(ClipboardEvent{Text: "shared identity"}) + if err != nil { + t.Fatal(err) + } + + changed, err := service.PinHistoryResolved("extension-local-id", "shared identity", true) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected text fallback to resolve pin target") + } + + deleted, err := service.DeleteHistoryResolved("another-local-id", "shared identity") + if err != nil { + t.Fatal(err) + } + if !deleted { + t.Fatal("expected text fallback to resolve delete target") + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 0 { + t.Fatalf("expected resolved entry %q to be deleted, got %#v", record.ID, history) + } +} + +func TestHistoryConflictOrderingPreventsStaleDeleteAndPin(t *testing.T) { + service := newTestService(t) + record, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "conflict item", + CreatedAt: 100, + OperationAt: 200, + }) + if err != nil { + t.Fatal(err) + } + + deleted, err := service.DeleteHistoryResolvedAt(record.ID, "conflict item", 150) + if err != nil { + t.Fatal(err) + } + if deleted { + t.Fatal("expected stale delete to be ignored") + } + + if changed, err := service.PinHistoryResolvedAt(record.ID, "conflict item", true, 250); err != nil || !changed { + t.Fatalf("expected newer pin to apply, changed=%v err=%v", changed, err) + } + if changed, err := service.PinHistoryResolvedAt(record.ID, "conflict item", false, 240); err != nil || changed { + t.Fatalf("expected older unpin to be ignored, changed=%v err=%v", changed, err) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 || !history[0].Pinned { + t.Fatalf("expected stale mutations to preserve pinned item, got %#v", history) + } +} + +func TestHistoryTombstonePreventsStaleResurrection(t *testing.T) { + service := newTestService(t) + record, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "deleted item", + CreatedAt: 100, + OperationAt: 100, + }) + if err != nil { + t.Fatal(err) + } + if deleted, err := service.DeleteHistoryResolvedAt(record.ID, "deleted item", 200); err != nil || !deleted { + t.Fatalf("expected delete to apply, deleted=%v err=%v", deleted, err) + } + + if _, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "deleted item", + CreatedAt: 150, + OperationAt: 150, + }); err != nil { + t.Fatal(err) + } + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 0 { + t.Fatalf("expected stale upsert to remain deleted, got %#v", history) + } + + if _, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "deleted item", + CreatedAt: 250, + OperationAt: 250, + }); err != nil { + t.Fatal(err) + } + history, err = service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 || history[0].Text != "deleted item" { + t.Fatalf("expected newer upsert to recreate item, got %#v", history) + } +} + +func TestClearHistoryHonorsOperationOrdering(t *testing.T) { + service := newTestService(t) + if _, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "old before clear", + CreatedAt: 100, + OperationAt: 100, + }); err != nil { + t.Fatal(err) + } + cleared, err := service.ClearHistoryAt(200) + if err != nil { + t.Fatal(err) + } + if cleared != 1 { + t.Fatalf("expected one item cleared, got %d", cleared) + } + + if _, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "old before clear", + CreatedAt: 150, + OperationAt: 150, + }); err != nil { + t.Fatal(err) + } + if _, err := service.RecordClipboardEvent(ClipboardEvent{ + Text: "new after clear", + CreatedAt: 250, + OperationAt: 250, + }); err != nil { + t.Fatal(err) + } + cleared, err = service.ClearHistoryAt(225) + if err != nil { + t.Fatal(err) + } + if cleared != 0 { + t.Fatalf("expected older clear to preserve newer item, got %d cleared", cleared) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 || history[0].Text != "new after clear" { + t.Fatalf("expected clear to block stale resurrection only, got %#v", history) + } +} + +func TestStoreMigratesV2HistoryTimestamps(t *testing.T) { + root := t.TempDir() + storePath := filepath.Join(root, "history.json") + payload := []byte(`{ + "schemaVersion": 2, + "updatedAt": 10, + "history": [{ + "id": "", + "text": "migrated", + "snippet": "", + "format": "", + "source": "web-extension", + "mode": "copy", + "createdAt": 123, + "pinned": true + }], + "settings": { + "historyEnabled": true, + "maxItems": 500, + "autoStart": false, + "hotkey": "Ctrl+Shift+Space" + } +}`) + if err := os.WriteFile(storePath, payload, 0o600); err != nil { + t.Fatal(err) + } + store := NewStore(storePath) + file, err := store.Load() + if err != nil { + t.Fatal(err) + } + if file.SchemaVersion != StoreSchemaVersion { + t.Fatalf("expected schema %d, got %d", StoreSchemaVersion, file.SchemaVersion) + } + if len(file.History) != 1 || file.History[0].UpdatedAt != 123 || file.History[0].PinUpdatedAt != 123 { + t.Fatalf("expected migrated timestamps, got %#v", file.History) + } +} + +func TestClearHistoryPersistsEmptyArray(t *testing.T) { + service := newTestService(t) + if _, err := service.RecordClipboardEvent(ClipboardEvent{Text: "clear me"}); err != nil { + t.Fatal(err) + } + cleared, err := service.ClearHistory() + if err != nil { + t.Fatal(err) + } + if cleared != 1 { + t.Fatalf("expected one cleared item, got %d", cleared) + } + + var raw map[string]json.RawMessage + bytes, err := os.ReadFile(service.StorePath()) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(bytes, &raw); err != nil { + t.Fatal(err) + } + if string(raw["history"]) != "[]" { + t.Fatalf("expected history to serialize as [], got %s", raw["history"]) + } +} + +func TestStoreRecoversFromBackupWhenPrimaryIsCorrupt(t *testing.T) { + service := newTestService(t) + record, err := service.RecordClipboardEvent(ClipboardEvent{Text: "backup item"}) + if err != nil { + t.Fatal(err) + } + backupBytes, err := os.ReadFile(service.StorePath()) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(service.StorePath()+".bak", backupBytes, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(service.StorePath(), []byte("{not-json"), 0o600); err != nil { + t.Fatal(err) + } + + history, err := service.ListHistory(ListHistoryOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 || history[0].ID != record.ID { + t.Fatalf("expected backup history to load, got %#v", history) + } + matches, err := filepath.Glob(service.StorePath() + ".corrupt-*") + if err != nil { + t.Fatal(err) + } + if len(matches) == 0 { + t.Fatal("expected corrupt primary file to be quarantined") + } +} + +func TestStoreFileLockPreservesConcurrentWriters(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("cross-process file lock is Windows-only in the MVP") + } + root := t.TempDir() + storePath := filepath.Join(root, "history.json") + storeA := NewStore(storePath) + storeB := NewStore(storePath) + + const writers = 20 + var wait sync.WaitGroup + errs := make(chan error, writers) + for index := 0; index < writers; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + store := storeA + if index%2 == 0 { + store = storeB + } + _, err := store.AddHistoryEntry(HistoryEntry{ + Text: "item " + string(rune('A'+index)), + CreatedAt: int64(index + 1), + }, 100) + errs <- err + }(index) + } + wait.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + + history, err := storeA.ListHistory(ListHistoryOptions{Limit: 100}) + if err != nil { + t.Fatal(err) + } + if len(history) != writers { + t.Fatalf("expected %d entries after concurrent writes, got %d: %#v", writers, len(history), history) + } +} diff --git a/companion/internal/companion/smart.go b/companion/internal/companion/smart.go new file mode 100644 index 0000000..710084c --- /dev/null +++ b/companion/internal/companion/smart.go @@ -0,0 +1,450 @@ +package companion + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +func DetectFormat(text string) string { + trimmed := strings.TrimSpace(text) + lower := strings.ToLower(trimmed) + if trimmed == "" { + return "plain" + } + if isJWT(trimmed) { + return "jwt" + } + if isTimestamp(trimmed) { + return "timestamp" + } + if isDate(trimmed) { + return "date" + } + if json.Valid([]byte(trimmed)) && (strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) { + return "json" + } + for _, keyword := range []string{"select", "insert", "update", "delete", "with", "create", "alter", "drop"} { + if lower == keyword || strings.HasPrefix(lower, keyword+" ") { + return "sql" + } + } + if looksBase64(trimmed) { + return "base64" + } + return "plain" +} + +func ApplyAction(text string, action string) string { + switch action { + case "prettyJson": + var out bytes.Buffer + if err := json.Indent(&out, []byte(strings.TrimSpace(text)), "", " "); err == nil { + return out.String() + } + case "minifyJson": + var out bytes.Buffer + if err := json.Compact(&out, []byte(strings.TrimSpace(text))); err == nil { + return out.String() + } + case "formatSql": + return formatSQL(text) + case "minifySql": + return minifySQL(text) + case "decodeJwt": + return decodeJWT(text) + case "timestampToIso", "timestampToDate": + return timestampToISO(text) + case "isoToTimestamp", "dateToTimestamp": + return isoToTimestamp(text) + case "upper": + return strings.ToUpper(text) + case "lower": + return strings.ToLower(text) + case "title": + return toTitle(text) + case "camel": + return toCamel(text) + case "snake": + return toSnake(text) + case "base64Encode": + return base64.StdEncoding.EncodeToString([]byte(text)) + case "base64Decode": + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(text)) + if err == nil { + return string(decoded) + } + } + return text +} + +func ActionsForFormat(format string) []SmartAction { + switch format { + case "json": + return []SmartAction{{ID: "prettyJson", Label: "Pretty JSON"}, {ID: "minifyJson", Label: "Minify JSON"}} + case "sql": + return []SmartAction{{ID: "formatSql", Label: "Format SQL"}, {ID: "minifySql", Label: "Minify SQL"}} + case "jwt": + return []SmartAction{{ID: "decodeJwt", Label: "Decode JWT (unverified)"}} + case "timestamp": + return []SmartAction{{ID: "timestampToIso", Label: "To ISO"}} + case "date": + return []SmartAction{{ID: "isoToTimestamp", Label: "To timestamp"}} + case "base64": + return []SmartAction{{ID: "base64Decode", Label: "Decode Base64"}} + default: + return []SmartAction{ + {ID: "upper", Label: "UPPER"}, + {ID: "lower", Label: "lower"}, + {ID: "title", Label: "Title"}, + {ID: "camel", Label: "camelCase"}, + {ID: "snake", Label: "snake_case"}, + {ID: "base64Encode", Label: "Encode Base64"}, + } + } +} + +func isJWT(text string) bool { + parts := strings.Split(text, ".") + if len(parts) != 3 || !strings.HasPrefix(parts[0], "eyJ") { + return false + } + header, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || !json.Valid(header) { + return false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + return err == nil && json.Valid(payload) +} + +func isTimestamp(text string) bool { + if len(text) != 10 && len(text) != 13 { + return false + } + for _, char := range text { + if char < '0' || char > '9' { + return false + } + } + value, err := strconv.ParseInt(text, 10, 64) + if err != nil { + return false + } + var date time.Time + if len(text) == 13 { + date = time.UnixMilli(value) + } else { + date = time.Unix(value, 0) + } + year := date.UTC().Year() + return year > 1975 && year < 2100 +} + +func isDate(text string) bool { + layouts := []string{ + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if _, err := time.Parse(layout, text); err == nil { + return true + } + } + return false +} + +func looksBase64(text string) bool { + if len(text) < 12 || len(text)%4 != 0 || strings.ContainsAny(text, " \r\n\t") { + return false + } + decoded, err := base64.StdEncoding.DecodeString(text) + if err != nil || len(decoded) == 0 || !utf8.Valid(decoded) { + return false + } + printable := 0 + for _, char := range string(decoded) { + if char == '\n' || char == '\r' || char == '\t' || !unicode.IsControl(char) { + printable++ + } + } + if printable < len([]rune(string(decoded))) { + return false + } + return base64.StdEncoding.EncodeToString(decoded) == text +} + +func formatSQL(text string) string { + return strings.TrimSpace(transformSQLCode(text, func(segment string) string { + normalized := collapseSQLCodeSegment(segment) + replacements := []struct { + from string + to string + }{ + {"select ", "SELECT "}, + {" from ", "\nFROM "}, + {" where ", "\nWHERE "}, + {" group by ", "\nGROUP BY "}, + {" order by ", "\nORDER BY "}, + {" left join ", "\nLEFT JOIN "}, + {" right join ", "\nRIGHT JOIN "}, + {" inner join ", "\nINNER JOIN "}, + {" join ", "\nJOIN "}, + {" values ", "\nVALUES "}, + {" set ", "\nSET "}, + } + out := normalized + out = replaceWordPrefixInsensitive(out, "select", "SELECT") + out = replaceWordPrefixInsensitive(out, "insert", "INSERT") + out = replaceWordPrefixInsensitive(out, "update", "UPDATE") + out = replaceWordPrefixInsensitive(out, "delete", "DELETE") + out = replaceWordPrefixInsensitive(out, "with", "WITH") + out = replacePhrasePrefixInsensitive(out, "group by", "GROUP BY") + out = replacePhrasePrefixInsensitive(out, "order by", "ORDER BY") + out = replacePhrasePrefixInsensitive(out, "left join", "LEFT JOIN") + out = replacePhrasePrefixInsensitive(out, "right join", "RIGHT JOIN") + out = replacePhrasePrefixInsensitive(out, "inner join", "INNER JOIN") + out = replaceWordPrefixInsensitive(out, "from", "FROM") + out = replaceWordPrefixInsensitive(out, "where", "WHERE") + out = replaceWordPrefixInsensitive(out, "having", "HAVING") + out = replaceWordPrefixInsensitive(out, "limit", "LIMIT") + out = replaceWordPrefixInsensitive(out, "join", "JOIN") + out = replaceWordPrefixInsensitive(out, "values", "VALUES") + out = replaceWordPrefixInsensitive(out, "set", "SET") + for _, replacement := range replacements { + out = replaceInsensitive(out, replacement.from, replacement.to) + } + return out + })) +} + +func minifySQL(text string) string { + return strings.TrimSpace(transformSQLCode(text, func(segment string) string { + return collapseSQLCodeSegment(segment) + })) +} + +func collapseSQLCodeSegment(segment string) string { + if strings.TrimSpace(segment) == "" { + if segment == "" { + return "" + } + return " " + } + collapsed := strings.Join(strings.Fields(segment), " ") + if strings.HasPrefix(segment, " ") || strings.HasPrefix(segment, "\n") || strings.HasPrefix(segment, "\t") || strings.HasPrefix(segment, "\r") { + collapsed = " " + collapsed + } + if strings.HasSuffix(segment, " ") || strings.HasSuffix(segment, "\n") || strings.HasSuffix(segment, "\t") || strings.HasSuffix(segment, "\r") { + collapsed += " " + } + return collapsed +} + +func transformSQLCode(text string, transform func(string) string) string { + var output strings.Builder + var code strings.Builder + + flushCode := func() { + if code.Len() == 0 { + return + } + output.WriteString(transform(code.String())) + code.Reset() + } + + for index := 0; index < len(text); { + if strings.HasPrefix(text[index:], "--") { + flushCode() + end := strings.IndexByte(text[index:], '\n') + if end < 0 { + output.WriteString(text[index:]) + break + } + output.WriteString(text[index : index+end+1]) + index += end + 1 + continue + } + if strings.HasPrefix(text[index:], "/*") { + flushCode() + end := strings.Index(text[index+2:], "*/") + if end < 0 { + output.WriteString(text[index:]) + break + } + end += index + 4 + output.WriteString(text[index:end]) + index = end + continue + } + if text[index] == '\'' || text[index] == '"' { + flushCode() + quote := text[index] + start := index + index++ + for index < len(text) { + if text[index] == quote { + if index+1 < len(text) && text[index+1] == quote { + index += 2 + continue + } + index++ + break + } + index++ + } + output.WriteString(text[start:index]) + continue + } + code.WriteByte(text[index]) + index++ + } + flushCode() + return output.String() +} + +func replaceInsensitive(input string, needle string, replacement string) string { + if needle == "" { + return input + } + + lowerInput := strings.ToLower(input) + lowerNeedle := strings.ToLower(needle) + var builder strings.Builder + start := 0 + for { + index := strings.Index(lowerInput[start:], lowerNeedle) + if index < 0 { + builder.WriteString(input[start:]) + break + } + index += start + builder.WriteString(input[start:index]) + builder.WriteString(replacement) + start = index + len(needle) + } + return builder.String() +} + +func replaceWordPrefixInsensitive(input string, needle string, replacement string) string { + return replacePhrasePrefixInsensitive(input, needle, replacement) +} + +func replacePhrasePrefixInsensitive(input string, needle string, replacement string) string { + trimmedLeft := strings.TrimLeft(input, " \t\r\n") + prefixLen := len(input) - len(trimmedLeft) + if len(trimmedLeft) < len(needle) || !strings.EqualFold(trimmedLeft[:len(needle)], needle) { + return input + } + if len(trimmedLeft) > len(needle) { + next := trimmedLeft[len(needle)] + if (next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z') || (next >= '0' && next <= '9') || next == '_' { + return input + } + } + return input[:prefixLen] + replacement + trimmedLeft[len(needle):] +} + +func decodeJWT(text string) string { + parts := strings.Split(strings.TrimSpace(text), ".") + if len(parts) != 3 { + return text + } + header := decodeJWTPart(parts[0]) + payload := decodeJWTPart(parts[1]) + if header == "" || payload == "" { + return text + } + return "HEADER\n" + header + "\n\nPAYLOAD\n" + payload +} + +func decodeJWTPart(value string) string { + decoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return "" + } + var pretty bytes.Buffer + if err := json.Indent(&pretty, decoded, "", " "); err == nil { + return pretty.String() + } + return string(decoded) +} + +func timestampToISO(text string) string { + value, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64) + if err != nil { + return text + } + if len(strings.TrimSpace(text)) == 13 { + return time.UnixMilli(value).UTC().Format(time.RFC3339) + } + return time.Unix(value, 0).UTC().Format(time.RFC3339) +} + +func isoToTimestamp(text string) string { + layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} + for _, layout := range layouts { + parsed, err := time.Parse(layout, strings.TrimSpace(text)) + if err == nil { + return fmt.Sprintf("%d", parsed.Unix()) + } + } + return text +} + +func toTitle(text string) string { + words := splitWords(text) + for index, word := range words { + if word == "" { + continue + } + runes := []rune(strings.ToLower(word)) + runes[0] = unicode.ToUpper(runes[0]) + words[index] = string(runes) + } + return strings.Join(words, " ") +} + +func toCamel(text string) string { + words := splitWords(text) + if len(words) == 0 { + return "" + } + for index, word := range words { + word = strings.ToLower(word) + if index > 0 && word != "" { + runes := []rune(word) + runes[0] = unicode.ToUpper(runes[0]) + word = string(runes) + } + words[index] = word + } + return strings.Join(words, "") +} + +func toSnake(text string) string { + words := splitWords(text) + for index, word := range words { + words[index] = strings.ToLower(word) + } + return strings.Join(words, "_") +} + +func splitWords(text string) []string { + fields := strings.FieldsFunc(text, func(char rune) bool { + return !(unicode.IsLetter(char) || unicode.IsDigit(char)) + }) + words := make([]string, 0, len(fields)) + for _, field := range fields { + if field != "" { + words = append(words, field) + } + } + return words +} diff --git a/companion/internal/companion/smart_test.go b/companion/internal/companion/smart_test.go new file mode 100644 index 0000000..e8ca716 --- /dev/null +++ b/companion/internal/companion/smart_test.go @@ -0,0 +1,74 @@ +package companion + +import ( + "encoding/base64" + "strings" + "testing" +) + +func TestSmartFormatAndActions(t *testing.T) { + if DetectFormat("{\"ok\":true}") != "json" { + t.Fatal("expected json format") + } + if got := ApplyAction("{\"ok\":true}", "prettyJson"); !strings.Contains(got, "\n \"ok\"") { + t.Fatalf("expected pretty json, got %q", got) + } + if got := ApplyAction("select * from users where id=1", "formatSql"); !strings.Contains(got, "\nFROM") { + t.Fatalf("expected formatted sql, got %q", got) + } + if got := ApplyAction("Hello world", "snake"); got != "hello_world" { + t.Fatalf("expected snake case, got %q", got) + } +} + +func TestDecodeJWT(t *testing.T) { + header := base64.RawURLEncoding.EncodeToString([]byte("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")) + payload := base64.RawURLEncoding.EncodeToString([]byte("{\"sub\":\"123\"}")) + decoded := ApplyAction(header+"."+payload+".signature", "decodeJwt") + if !strings.Contains(decoded, "HEADER") || !strings.Contains(decoded, "\"sub\": \"123\"") { + t.Fatalf("unexpected jwt decode output: %s", decoded) + } +} + +func TestSQLActionsPreserveStringLiteralsAndComments(t *testing.T) { + sql := "select 'from literal' as value -- keep comment\nfrom users where note='order by text'" + formatted := ApplyAction(sql, "formatSql") + if !strings.Contains(formatted, "'from literal'") { + t.Fatalf("formatSql changed string literal: %q", formatted) + } + if !strings.Contains(formatted, "-- keep comment") { + t.Fatalf("formatSql changed comment: %q", formatted) + } + if !strings.Contains(formatted, "\nFROM users") { + t.Fatalf("formatSql did not format SQL clause: %q", formatted) + } + + minified := ApplyAction(sql, "minifySql") + if !strings.Contains(minified, "'order by text'") || !strings.Contains(minified, "-- keep comment") { + t.Fatalf("minifySql changed protected SQL text: %q", minified) + } +} + +func TestBase64DetectionIsStrict(t *testing.T) { + if DetectFormat(base64.StdEncoding.EncodeToString([]byte("Hello world"))) != "base64" { + t.Fatal("expected printable base64 to be detected") + } + if DetectFormat("abcdefghijkl") == "base64" { + t.Fatal("expected arbitrary lowercase token to stay plain") + } + if got := ApplyAction("not base64 text", "base64Decode"); got != "not base64 text" { + t.Fatalf("expected invalid base64 decode to be no-op, got %q", got) + } +} + +func TestTimestampActionAliases(t *testing.T) { + if got := ApplyAction("1717886400", "timestampToIso"); !strings.Contains(got, "2024-06") { + t.Fatalf("expected timestampToIso to format timestamp, got %q", got) + } + if got := ApplyAction("1717886400", "timestampToDate"); !strings.Contains(got, "2024-06") { + t.Fatalf("expected timestampToDate alias to format timestamp, got %q", got) + } + if got := ApplyAction("2024-06-09T00:00:00Z", "dateToTimestamp"); got != "1717891200" { + t.Fatalf("expected dateToTimestamp alias, got %q", got) + } +} diff --git a/companion/internal/companion/store.go b/companion/internal/companion/store.go new file mode 100644 index 0000000..d75377f --- /dev/null +++ b/companion/internal/companion/store.go @@ -0,0 +1,844 @@ +package companion + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +const recentCanonicalDuplicateWindowMs int64 = 8000 + +type Store struct { + path string + mu sync.Mutex +} + +func NewStore(path string) *Store { + return &Store{path: path} +} + +func (store *Store) Path() string { + return store.path +} + +func (store *Store) Load() (StoreFile, error) { + var file StoreFile + err := store.withLock(func() error { + var loadErr error + file, loadErr = store.loadLocked() + return loadErr + }) + return file, err +} + +func (store *Store) AddHistoryEntry(entry HistoryEntry, limit int) (HistoryEntry, error) { + var savedEntry HistoryEntry + err := store.withLock(func() error { + file, err := store.loadLocked() + if err != nil { + return err + } + + if limit <= 0 { + limit = file.Settings.MaxItems + } + if limit <= 0 { + limit = DefaultHistoryLimit + } + + normalizedEntry, ok := normalizeHistoryEntry(entry) + if !ok { + return errors.New("history entry text is empty") + } + operationAt := historyOperationTime(normalizedEntry) + if duplicateID := resolveRecentCanonicalDuplicate(file.History, normalizedEntry, operationAt); duplicateID != "" { + normalizedEntry.ID = duplicateID + } + if shouldIgnoreUpsert(file, normalizedEntry.ID, operationAt) { + savedEntry = normalizedEntry + return nil + } + + next := make([]HistoryEntry, 0, len(file.History)+1) + inserted := normalizedEntry + replaced := false + for _, existing := range file.History { + normalizedExisting, ok := normalizeHistoryEntry(existing) + if !ok { + continue + } + if normalizedExisting.ID == normalizedEntry.ID { + inserted = mergeHistoryEntry(normalizedExisting, normalizedEntry) + replaced = true + continue + } + next = append(next, normalizedExisting) + } + next = append(next, inserted) + + file.Tombstones = removeHistoryTombstone(file.Tombstones, inserted.ID) + file.History = trimHistoryEntries(sortHistory(next), limit) + if !replaced { + for _, item := range file.History { + if item.ID == inserted.ID { + savedEntry = item + break + } + } + } else { + savedEntry = inserted + } + if savedEntry.ID == "" { + savedEntry = inserted + } + + return store.saveLocked(file) + }) + return savedEntry, err +} + +func (store *Store) ListHistory(options ListHistoryOptions) ([]HistoryEntry, error) { + var entries []HistoryEntry + err := store.withLock(func() error { + file, err := store.loadLocked() + if err != nil { + return err + } + + query := strings.ToLower(strings.TrimSpace(options.Query)) + entries = make([]HistoryEntry, 0, len(file.History)) + for _, entry := range sortHistory(file.History) { + if query != "" && !historyMatches(entry, query) { + continue + } + entries = append(entries, entry) + } + + limit := historyListLimit(file.History, options.Limit, file.Settings.MaxItems) + if len(entries) > limit { + entries = entries[:limit] + } + return nil + }) + return entries, err +} + +func (store *Store) DeleteHistory(id string) (bool, error) { + return store.DeleteHistoryAt(id, time.Now().UnixMilli()) +} + +func (store *Store) DeleteHistoryAt(id string, operationAt int64) (bool, error) { + return store.DeleteHistoryResolvedAt(id, "", operationAt) +} + +func (store *Store) DeleteHistoryResolved(id string, text string) (bool, error) { + return store.DeleteHistoryResolvedAt(id, text, time.Now().UnixMilli()) +} + +func (store *Store) DeleteHistoryResolvedAt(id string, text string, operationAt int64) (bool, error) { + deleted := false + err := store.withLock(func() error { + file, err := store.loadLocked() + if err != nil { + return err + } + + operationAt = normalizeOperationAt(operationAt) + targetID := resolveHistoryMutationID(file.History, id, text) + if targetID == "" { + targetID = fallbackHistoryMutationID(id, text) + } + if targetID == "" { + return nil + } + next := make([]HistoryEntry, 0, len(file.History)) + staleDelete := false + for _, entry := range file.History { + if entry.ID == targetID { + if historyOperationTime(entry) > operationAt { + next = append(next, entry) + staleDelete = true + continue + } + deleted = true + continue + } + next = append(next, entry) + } + if staleDelete { + return nil + } + file.Tombstones = upsertHistoryTombstone(file.Tombstones, targetID, operationAt) + if !deleted && tombstoneDeletedAt(file.Tombstones, targetID) > operationAt { + return nil + } + file.History = next + return store.saveLocked(file) + }) + return deleted, err +} + +func (store *Store) ClearHistory() (int, error) { + return store.ClearHistoryAt(time.Now().UnixMilli()) +} + +func (store *Store) ClearHistoryAt(operationAt int64) (int, error) { + cleared := 0 + err := store.withLock(func() error { + file, err := store.loadLocked() + if err != nil { + return err + } + operationAt = normalizeOperationAt(operationAt) + if operationAt > file.ClearedAt { + file.ClearedAt = operationAt + } + next := make([]HistoryEntry, 0, len(file.History)) + for _, entry := range file.History { + if historyOperationTime(entry) > operationAt { + next = append(next, entry) + continue + } + cleared++ + } + file.History = next + return store.saveLocked(file) + }) + return cleared, err +} + +func (store *Store) PinHistory(id string, pinned bool) (bool, error) { + return store.PinHistoryAt(id, pinned, time.Now().UnixMilli()) +} + +func (store *Store) PinHistoryAt(id string, pinned bool, operationAt int64) (bool, error) { + return store.PinHistoryResolvedAt(id, "", pinned, operationAt) +} + +func (store *Store) PinHistoryResolved(id string, text string, pinned bool) (bool, error) { + return store.PinHistoryResolvedAt(id, text, pinned, time.Now().UnixMilli()) +} + +func (store *Store) PinHistoryResolvedAt(id string, text string, pinned bool, operationAt int64) (bool, error) { + changed := false + err := store.withLock(func() error { + file, err := store.loadLocked() + if err != nil { + return err + } + + operationAt = normalizeOperationAt(operationAt) + targetID := resolveHistoryMutationID(file.History, id, text) + if targetID == "" { + return nil + } + for index := range file.History { + if file.History[index].ID == targetID { + if file.History[index].PinUpdatedAt > operationAt || historyTombstoneBlocks(file, targetID, operationAt) { + return nil + } + if file.History[index].Pinned == pinned { + return nil + } + file.History[index].Pinned = pinned + file.History[index].PinUpdatedAt = operationAt + changed = true + break + } + } + if !changed { + return nil + } + file.History = sortHistory(file.History) + return store.saveLocked(file) + }) + return changed, err +} + +func resolveHistoryMutationID(history []HistoryEntry, id string, text string) string { + for _, entry := range history { + if id != "" && entry.ID == id { + return id + } + } + if strings.TrimSpace(text) == "" { + return "" + } + fallbackID := HistoryID(text) + for _, entry := range history { + if entry.ID == fallbackID { + return fallbackID + } + } + canonicalText := canonicalHistoryText(text) + if canonicalText != "" { + for _, entry := range sortHistory(history) { + if canonicalHistoryText(entry.Text) == canonicalText { + return entry.ID + } + } + } + return "" +} + +func resolveRecentCanonicalDuplicate(history []HistoryEntry, incoming HistoryEntry, operationAt int64) string { + canonicalText := canonicalHistoryText(incoming.Text) + if canonicalText == "" { + return "" + } + var matchedID string + var matchedAt int64 + for _, entry := range history { + if entry.ID == incoming.ID || canonicalHistoryText(entry.Text) != canonicalText { + continue + } + entryAt := historyOperationTime(entry) + if !withinRecentDuplicateWindow(entryAt, operationAt) && !withinRecentDuplicateWindow(entry.CreatedAt, incoming.CreatedAt) { + continue + } + if matchedID == "" || entryAt > matchedAt { + matchedID = entry.ID + matchedAt = entryAt + } + } + return matchedID +} + +func canonicalHistoryText(text string) string { + return strings.ToLower(strings.Join(strings.Fields(text), " ")) +} + +func withinRecentDuplicateWindow(left int64, right int64) bool { + if left <= 0 || right <= 0 { + return false + } + delta := left - right + if delta < 0 { + delta = -delta + } + return delta <= recentCanonicalDuplicateWindowMs +} + +func fallbackHistoryMutationID(id string, text string) string { + if id != "" { + return id + } + if strings.TrimSpace(text) == "" { + return "" + } + return HistoryID(text) +} + +func shouldIgnoreUpsert(file StoreFile, id string, operationAt int64) bool { + if operationAt <= 0 { + return false + } + if file.ClearedAt > 0 && operationAt <= file.ClearedAt { + return true + } + return historyTombstoneBlocks(file, id, operationAt) +} + +func historyTombstoneBlocks(file StoreFile, id string, operationAt int64) bool { + return id != "" && tombstoneDeletedAt(file.Tombstones, id) >= operationAt && operationAt > 0 +} + +func tombstoneDeletedAt(tombstones []HistoryTombstone, id string) int64 { + for _, tombstone := range tombstones { + if tombstone.ID == id { + return tombstone.DeletedAt + } + } + return 0 +} + +func upsertHistoryTombstone(tombstones []HistoryTombstone, id string, deletedAt int64) []HistoryTombstone { + if id == "" || deletedAt <= 0 { + return tombstones + } + next := make([]HistoryTombstone, 0, len(tombstones)+1) + replaced := false + for _, tombstone := range normalizeTombstones(tombstones) { + if tombstone.ID == id { + if tombstone.DeletedAt < deletedAt { + tombstone.DeletedAt = deletedAt + } + replaced = true + } + next = append(next, tombstone) + } + if !replaced { + next = append(next, HistoryTombstone{ID: id, DeletedAt: deletedAt}) + } + return next +} + +func removeHistoryTombstone(tombstones []HistoryTombstone, id string) []HistoryTombstone { + if id == "" { + return normalizeTombstones(tombstones) + } + next := make([]HistoryTombstone, 0, len(tombstones)) + for _, tombstone := range normalizeTombstones(tombstones) { + if tombstone.ID != id { + next = append(next, tombstone) + } + } + return next +} + +func normalizeTombstones(tombstones []HistoryTombstone) []HistoryTombstone { + latest := make(map[string]int64, len(tombstones)) + for _, tombstone := range tombstones { + if tombstone.ID == "" || tombstone.DeletedAt <= 0 { + continue + } + if latest[tombstone.ID] < tombstone.DeletedAt { + latest[tombstone.ID] = tombstone.DeletedAt + } + } + next := make([]HistoryTombstone, 0, len(latest)) + for id, deletedAt := range latest { + next = append(next, HistoryTombstone{ID: id, DeletedAt: deletedAt}) + } + sort.SliceStable(next, func(i, j int) bool { + if next[i].DeletedAt != next[j].DeletedAt { + return next[i].DeletedAt > next[j].DeletedAt + } + return next[i].ID < next[j].ID + }) + return next +} + +func (store *Store) GetSettings() (Settings, error) { + var settings Settings + err := store.withLock(func() error { + file, err := store.loadLocked() + if err != nil { + return err + } + settings = file.Settings + return nil + }) + return settings, err +} + +func (store *Store) UpdateSettings(patch SettingsPatch) (Settings, error) { + var settings Settings + err := store.withLock(func() error { + file, err := store.loadLocked() + if err != nil { + return err + } + + settings = file.Settings + if patch.HistoryEnabled != nil { + settings.HistoryEnabled = *patch.HistoryEnabled + } + if patch.MaxItems != nil { + settings.MaxItems = clampMaxItems(*patch.MaxItems) + } + if patch.AutoStart != nil { + settings.AutoStart = *patch.AutoStart + } + if patch.Hotkey != nil { + settings.Hotkey = normalizeHotkey(*patch.Hotkey) + } + + file.Settings = settings + file.History = trimHistoryEntries(sortHistory(file.History), settings.MaxItems) + return store.saveLocked(file) + }) + return settings, err +} + +func (store *Store) withLock(fn func() error) error { + store.mu.Lock() + defer store.mu.Unlock() + return withFileLock(store.path+".lock", fn) +} + +func (store *Store) loadLocked() (StoreFile, error) { + fileBytes, err := os.ReadFile(store.path) + if errors.Is(err, os.ErrNotExist) { + return migrateStore(StoreFile{}), nil + } + if err != nil { + return StoreFile{}, err + } + + var file StoreFile + if err := json.Unmarshal(fileBytes, &file); err != nil { + backup, backupErr := store.loadBackupLocked() + if backupErr == nil { + _ = os.Rename(store.path, fmt.Sprintf("%s.corrupt-%d", store.path, time.Now().UnixMilli())) + return backup, nil + } + return StoreFile{}, fmt.Errorf("%w; backup recovery failed: %v", err, backupErr) + } + return migrateStore(file), nil +} + +func (store *Store) saveLocked(file StoreFile) error { + if file.SchemaVersion <= 0 { + file.SchemaVersion = StoreSchemaVersion + } + if file.Settings.MaxItems <= 0 { + file.Settings.MaxItems = DefaultSettings().MaxItems + } + if file.Settings.Hotkey == "" { + file.Settings.Hotkey = DefaultSettings().Hotkey + } + if file.History == nil { + file.History = []HistoryEntry{} + } + if file.Tombstones == nil { + file.Tombstones = []HistoryTombstone{} + } + file.UpdatedAt = time.Now().UnixMilli() + if err := os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil { + return err + } + payload, err := json.MarshalIndent(file, "", " ") + if err != nil { + return err + } + if err := store.backupCurrentLocked(); err != nil { + return err + } + return writeFileAtomic(store.path, payload, 0o600) +} + +func migrateStore(file StoreFile) StoreFile { + defaults := DefaultSettings() + oldSchema := file.SchemaVersion + if file.SchemaVersion <= 0 { + file.SchemaVersion = StoreSchemaVersion + } + if file.Settings.MaxItems == 0 && file.Settings.Hotkey == "" && file.UpdatedAt == 0 && oldSchema <= 0 { + file.Settings = defaults + } + file.Settings.MaxItems = clampMaxItems(defaultIfZero(file.Settings.MaxItems, defaults.MaxItems)) + if file.Settings.Hotkey == "" { + file.Settings.Hotkey = defaults.Hotkey + } + if !file.Settings.HistoryEnabled && oldSchema <= 0 && file.UpdatedAt == 0 { + file.Settings.HistoryEnabled = defaults.HistoryEnabled + } + nextHistory := make([]HistoryEntry, 0, len(file.History)) + for index := range file.History { + if normalized, ok := normalizeHistoryEntry(file.History[index]); ok { + nextHistory = append(nextHistory, normalized) + } + } + file.Tombstones = normalizeTombstones(file.Tombstones) + file.SchemaVersion = StoreSchemaVersion + file.History = trimHistoryEntries(sortHistory(deduplicateHistory(nextHistory)), file.Settings.MaxItems) + return file +} + +func (store *Store) loadBackupLocked() (StoreFile, error) { + backupBytes, err := os.ReadFile(store.path + ".bak") + if err != nil { + return StoreFile{}, err + } + var backup StoreFile + if err := json.Unmarshal(backupBytes, &backup); err != nil { + return StoreFile{}, err + } + return migrateStore(backup), nil +} + +func (store *Store) backupCurrentLocked() error { + fileBytes, err := os.ReadFile(store.path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var current StoreFile + if err := json.Unmarshal(fileBytes, ¤t); err != nil { + return nil + } + return writeFileAtomic(store.path+".bak", fileBytes, 0o600) +} + +func writeFileAtomic(target string, payload []byte, perm os.FileMode) error { + dir := filepath.Dir(target) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + tempFile, err := os.CreateTemp(dir, filepath.Base(target)+".tmp-*") + if err != nil { + return err + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + if _, err := tempFile.Write(payload); err != nil { + _ = tempFile.Close() + return err + } + if err := tempFile.Chmod(perm); err != nil { + _ = tempFile.Close() + return err + } + if err := tempFile.Sync(); err != nil { + _ = tempFile.Close() + return err + } + if err := tempFile.Close(); err != nil { + return err + } + return replaceFileAtomic(tempPath, target) +} + +func normalizeHistoryEntry(entry HistoryEntry) (HistoryEntry, bool) { + text := entry.Text + if strings.TrimSpace(text) == "" { + return HistoryEntry{}, false + } + text, truncated := truncateStringBytes(text, MaxStoredTextBytes) + if entry.ID == "" { + entry.ID = HistoryID(text) + } + if entry.CreatedAt <= 0 { + entry.CreatedAt = time.Now().UnixMilli() + } + if entry.UpdatedAt <= 0 { + entry.UpdatedAt = entry.CreatedAt + } + if entry.Pinned && entry.PinUpdatedAt <= 0 { + entry.PinUpdatedAt = entry.UpdatedAt + } + entry.Text = text + if entry.Snippet == "" { + entry.Snippet = Snippet(entry.Text, 90) + } else { + entry.Snippet = Snippet(entry.Snippet, 90) + } + if entry.Format == "" { + entry.Format = DetectFormat(entry.Text) + } + if entry.Source == "" { + entry.Source = "web-extension" + } + if entry.Mode == "" { + entry.Mode = "copy" + } + entry.Truncated = entry.Truncated || truncated + return entry, true +} + +func mergeHistoryEntry(existing HistoryEntry, incoming HistoryEntry) HistoryEntry { + merged := incoming + if historyOperationTime(existing) > historyOperationTime(incoming) { + merged = existing + } + if existing.PinUpdatedAt > incoming.PinUpdatedAt { + merged.Pinned = existing.Pinned + merged.PinUpdatedAt = existing.PinUpdatedAt + } else if incoming.PinUpdatedAt > existing.PinUpdatedAt { + merged.Pinned = incoming.Pinned + merged.PinUpdatedAt = incoming.PinUpdatedAt + } else if existing.Pinned || incoming.Pinned { + merged.Pinned = existing.Pinned || incoming.Pinned + merged.PinUpdatedAt = maxInt64(existing.PinUpdatedAt, incoming.PinUpdatedAt) + } + if existing.CreatedAt > merged.CreatedAt { + merged.CreatedAt = existing.CreatedAt + } + if incoming.CreatedAt > merged.CreatedAt { + merged.CreatedAt = incoming.CreatedAt + } + if shouldKeepExistingMetadata(existing, incoming) || historyOperationTime(existing) > historyOperationTime(incoming) { + merged.Source = existing.Source + merged.Mode = existing.Mode + merged.URL = existing.URL + merged.Hostname = existing.Hostname + merged.Title = existing.Title + merged.SelectionBased = existing.SelectionBased + } + if merged.URL == "" { + merged.URL = existing.URL + } + if merged.Hostname == "" { + merged.Hostname = existing.Hostname + } + if merged.Title == "" { + merged.Title = existing.Title + } + merged.UpdatedAt = maxInt64(existing.UpdatedAt, incoming.UpdatedAt) + merged.Truncated = existing.Truncated || incoming.Truncated + return merged +} + +func shouldKeepExistingMetadata(existing HistoryEntry, incoming HistoryEntry) bool { + if incoming.Source != "desktop-clipboard" { + return false + } + return existing.URL != "" || existing.Hostname != "" || existing.Title != "" || existing.Source != "desktop-clipboard" +} + +func trimHistoryEntries(entries []HistoryEntry, maxUnpinned int) []HistoryEntry { + if maxUnpinned <= 0 { + maxUnpinned = DefaultHistoryLimit + } + next := make([]HistoryEntry, 0, len(entries)) + unpinned := 0 + for _, entry := range sortHistory(entries) { + if entry.Pinned { + next = append(next, entry) + continue + } + if unpinned >= maxUnpinned { + continue + } + unpinned++ + next = append(next, entry) + } + return sortHistory(next) +} + +func deduplicateHistory(entries []HistoryEntry) []HistoryEntry { + seen := make(map[string]HistoryEntry, len(entries)) + for _, entry := range entries { + if existing, ok := seen[entry.ID]; ok { + seen[entry.ID] = mergeHistoryEntry(existing, entry) + continue + } + seen[entry.ID] = entry + } + next := make([]HistoryEntry, 0, len(seen)) + for _, entry := range seen { + next = append(next, entry) + } + return next +} + +func truncateStringBytes(text string, maxBytes int) (string, bool) { + if maxBytes <= 0 || len(text) <= maxBytes { + return text, false + } + last := 0 + for index := range text { + if index > maxBytes { + break + } + last = index + } + if last <= 0 { + return text[:maxBytes], true + } + return text[:last], true +} + +func sortHistory(entries []HistoryEntry) []HistoryEntry { + next := append([]HistoryEntry(nil), entries...) + sort.SliceStable(next, func(i, j int) bool { + if next[i].Pinned != next[j].Pinned { + return next[i].Pinned + } + return next[i].CreatedAt > next[j].CreatedAt + }) + return next +} + +func historyMatches(entry HistoryEntry, query string) bool { + haystack := strings.ToLower(strings.Join([]string{ + entry.Text, + entry.Snippet, + entry.Format, + entry.Source, + entry.URL, + entry.Hostname, + entry.Title, + }, " ")) + return strings.Contains(haystack, query) +} + +func historyListLimit(entries []HistoryEntry, requested int, maxUnpinned int) int { + if maxUnpinned <= 0 { + maxUnpinned = DefaultHistoryLimit + } + pinned := 0 + for _, entry := range entries { + if entry.Pinned { + pinned++ + } + } + defaultLimit := pinned + maxUnpinned + if requested <= 0 || requested > defaultLimit { + return defaultLimit + } + return requested +} + +func historyOperationTime(entry HistoryEntry) int64 { + if entry.UpdatedAt > 0 { + return entry.UpdatedAt + } + return entry.CreatedAt +} + +func normalizeOperationAt(operationAt int64) int64 { + if operationAt > 0 { + return operationAt + } + return time.Now().UnixMilli() +} + +func clampMaxItems(value int) int { + if value < 1 { + return 1 + } + if value > 5000 { + return 5000 + } + return value +} + +func defaultIfZero(value int, fallback int) int { + if value == 0 { + return fallback + } + return value +} + +func maxInt64(left int64, right int64) int64 { + if left > right { + return left + } + return right +} + +func normalizeHotkey(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return DefaultSettings().Hotkey + } + return trimmed +} + +func HistoryID(text string) string { + sum := sha256.Sum256([]byte(text)) + return fmt.Sprintf("%x", sum[:12]) +} + +func Snippet(text string, limit int) string { + collapsed := strings.Join(strings.Fields(text), " ") + runes := []rune(collapsed) + if len(runes) <= limit { + return collapsed + } + if limit < 4 { + limit = 4 + } + return string(runes[:limit-3]) + "..." +} diff --git a/companion/internal/companion/types.go b/companion/internal/companion/types.go new file mode 100644 index 0000000..4f88a6c --- /dev/null +++ b/companion/internal/companion/types.go @@ -0,0 +1,126 @@ +package companion + +import "encoding/json" + +const ProtocolVersion = 1 +const MaxNativeRequestBytes = 2 * 1024 * 1024 +const MaxNativeResponseBytes = 1024 * 1024 +const MaxNativeResponseTextBytes = 256 * 1024 +const StoreSchemaVersion = 3 +const DefaultHistoryLimit = 500 +const MaxStoredTextBytes = 512 * 1024 +const MaxPreviewTextBytes = 512 * 1024 + +type Envelope struct { + ID string `json:"id"` + Version int `json:"version"` + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +type Response struct { + ID string `json:"id"` + OK bool `json:"ok"` + Payload any `json:"payload,omitempty"` + Error *ErrorShape `json:"error,omitempty"` +} + +type ErrorShape struct { + Code string `json:"code"` + Message string `json:"message"` + Recoverable bool `json:"recoverable"` +} + +type ClipboardEvent struct { + Source string `json:"source"` + Mode string `json:"mode"` + Text string `json:"text"` + Snippet string `json:"snippet"` + Format string `json:"format"` + URL string `json:"url"` + Hostname string `json:"hostname"` + Title string `json:"title"` + CreatedAt int64 `json:"createdAt"` + OperationAt int64 `json:"operationAt"` + SelectionBased bool `json:"selectionBased"` + Truncated bool `json:"truncated"` +} + +type HistoryEntry struct { + ID string `json:"id"` + Text string `json:"text"` + Snippet string `json:"snippet"` + Format string `json:"format"` + Source string `json:"source"` + Mode string `json:"mode"` + URL string `json:"url"` + Hostname string `json:"hostname"` + Title string `json:"title"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + PinUpdatedAt int64 `json:"pinUpdatedAt,omitempty"` + Pinned bool `json:"pinned"` + SelectionBased bool `json:"selectionBased"` + Truncated bool `json:"truncated"` +} + +type HistoryTombstone struct { + ID string `json:"id"` + DeletedAt int64 `json:"deletedAt"` +} + +type Settings struct { + HistoryEnabled bool `json:"historyEnabled"` + MaxItems int `json:"maxItems"` + AutoStart bool `json:"autoStart"` + Hotkey string `json:"hotkey"` +} + +type SettingsPatch struct { + HistoryEnabled *bool `json:"historyEnabled,omitempty"` + MaxItems *int `json:"maxItems,omitempty"` + AutoStart *bool `json:"autoStart,omitempty"` + Hotkey *string `json:"hotkey,omitempty"` +} + +type StoreFile struct { + SchemaVersion int `json:"schemaVersion"` + UpdatedAt int64 `json:"updatedAt"` + ClearedAt int64 `json:"clearedAt,omitempty"` + History []HistoryEntry `json:"history"` + Tombstones []HistoryTombstone `json:"tombstones,omitempty"` + Settings Settings `json:"settings"` +} + +type ListHistoryOptions struct { + Limit int `json:"limit"` + Query string `json:"query"` +} + +type RecordResult struct { + Stored bool `json:"stored"` + ID string `json:"id,omitempty"` + Reason string `json:"reason,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +type FormatPreviewResult struct { + Format string `json:"format"` + Text string `json:"text"` + Actions []SmartAction `json:"actions"` + Truncated bool `json:"truncated,omitempty"` +} + +type SmartAction struct { + ID string `json:"id"` + Label string `json:"label"` +} + +func DefaultSettings() Settings { + return Settings{ + HistoryEnabled: true, + MaxItems: DefaultHistoryLimit, + AutoStart: false, + Hotkey: "Ctrl+Shift+Space", + } +} diff --git a/companion/main.go b/companion/main.go new file mode 100644 index 0000000..f0531f6 --- /dev/null +++ b/companion/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "embed" + "fmt" + "log" + "os" + "strings" + + "copytextcompanion/internal/companion" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/menu" + "github.com/wailsapp/wails/v2/pkg/menu/keys" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + service := companion.NewService(companion.DefaultPaths()) + + if isNativeMessagingInvocation(os.Args) { + if err := companion.RunNativeMessaging(os.Stdin, os.Stdout, service); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + + runDesktop(service, isStartupInvocation(os.Args)) +} + +func isNativeMessagingInvocation(args []string) bool { + if len(args) <= 1 { + return false + } + return args[1] == "--native-messaging" || strings.HasPrefix(args[1], "chrome-extension://") +} + +func isStartupInvocation(args []string) bool { + for _, arg := range args[1:] { + if arg == "--startup" { + return true + } + } + return false +} + +func runDesktop(service *companion.Service, startMinimized bool) { + app := NewApp(service) + startState := options.Normal + if startMinimized { + startState = options.Minimised + } + err := wails.Run(&options.App{ + Title: "Copy Text Companion", + Width: 1040, + Height: 720, + MinWidth: 720, + MinHeight: 500, + WindowStartState: startState, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + OnStartup: app.startup, + OnShutdown: app.shutdown, + SingleInstanceLock: &options.SingleInstanceLock{ + UniqueId: "6f0a9ea7-1b56-4f55-a76c-0f4c6b6fd43f", + OnSecondInstanceLaunch: func(options.SecondInstanceData) { + app.OpenApp() + }, + }, + Menu: createAppMenu(app), + Bind: []interface{}{ + app, + }, + }) + if err != nil { + log.Fatal(err) + } +} + +func createAppMenu(app *App) *menu.Menu { + appMenu := menu.NewMenu() + fileMenu := appMenu.AddSubmenu("Companion") + fileMenu.AddText("Show", keys.Combo("space", keys.ControlKey, keys.ShiftKey), func(_ *menu.CallbackData) { + app.OpenApp() + }) + fileMenu.AddText("Minimize", keys.Control("h"), func(_ *menu.CallbackData) { + app.HideApp() + }) + fileMenu.AddSeparator() + fileMenu.AddText("Quit", keys.Control("q"), func(_ *menu.CallbackData) { + app.Quit() + }) + return appMenu +} diff --git a/companion/main_test.go b/companion/main_test.go new file mode 100644 index 0000000..8551119 --- /dev/null +++ b/companion/main_test.go @@ -0,0 +1,43 @@ +package main + +import "testing" + +func TestIsNativeMessagingInvocation(t *testing.T) { + cases := []struct { + name string + args []string + want bool + }{ + {name: "explicit flag", args: []string{"companion.exe", "--native-messaging"}, want: true}, + {name: "chrome origin", args: []string{"companion.exe", "chrome-extension://abcdefghijklmnop/"}, want: true}, + {name: "desktop mode", args: []string{"companion.exe"}, want: false}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := isNativeMessagingInvocation(testCase.args); got != testCase.want { + t.Fatalf("expected %v, got %v", testCase.want, got) + } + }) + } +} + +func TestIsStartupInvocation(t *testing.T) { + cases := []struct { + name string + args []string + want bool + }{ + {name: "startup flag", args: []string{"companion.exe", "--startup"}, want: true}, + {name: "desktop mode", args: []string{"companion.exe"}, want: false}, + {name: "native messaging flag only", args: []string{"companion.exe", "--native-messaging"}, want: false}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := isStartupInvocation(testCase.args); got != testCase.want { + t.Fatalf("expected %v, got %v", testCase.want, got) + } + }) + } +} diff --git a/companion/tray_other.go b/companion/tray_other.go new file mode 100644 index 0000000..def1b93 --- /dev/null +++ b/companion/tray_other.go @@ -0,0 +1,21 @@ +//go:build !windows + +package main + +type trayActions struct { + Show func() bool + Minimize func() bool + Quit func() bool +} + +type trayManager struct{} + +func newTrayManager(trayActions) *trayManager { + return &trayManager{} +} + +func (manager *trayManager) Start() error { + return nil +} + +func (manager *trayManager) Stop() {} diff --git a/companion/tray_windows.go b/companion/tray_windows.go new file mode 100644 index 0000000..33492c0 --- /dev/null +++ b/companion/tray_windows.go @@ -0,0 +1,436 @@ +//go:build windows + +package main + +import ( + _ "embed" + "errors" + "fmt" + "runtime" + "sync" + "syscall" + "time" + "unsafe" +) + +//go:embed assets/appicon.png +var trayIconPNG []byte + +const ( + trayUID = 1 + trayCallbackMessage = 0x8000 + 0x531 + trayShutdownMessage = 0x8000 + 0x532 + + nimAdd = 0x00000000 + nimDelete = 0x00000002 + nimSetVersion = 0x00000004 + + nifMessage = 0x00000001 + nifIcon = 0x00000002 + nifTip = 0x00000004 + + notifyIconVersion4 = 4 + + wmNull = 0x0000 + wmDestroy = 0x0002 + wmContextMenu = 0x007b + wmCommand = 0x0111 + wmLButtonUp = 0x0202 + wmLButtonDblClk = 0x0203 + wmRButtonDown = 0x0204 + wmRButtonUp = 0x0205 + ninSelect = 0x0400 + ninKeySelect = 0x0401 + + csDblClks = 0x0008 + + mfString = 0x00000000 + mfSeparator = 0x00000800 + + tpmRightButton = 0x0002 + tpmReturnCmd = 0x0100 + + lrDefaultSize = 0x00000040 + + idiApplication = 32512 + idTrayShow = 1001 + idTrayMinimize = 1002 + idTrayQuit = 1003 + + errorClassAlreadyExists syscall.Errno = 1410 +) + +type trayActions struct { + Show func() bool + Minimize func() bool + Quit func() bool +} + +type trayManager struct { + actions trayActions + + mu sync.Mutex + hwnd uintptr + done chan struct{} +} + +type notifyIconData struct { + CbSize uint32 + HWnd uintptr + UID uint32 + UFlags uint32 + UCallbackMessage uint32 + HIcon uintptr + SzTip [128]uint16 + DwState uint32 + DwStateMask uint32 + SzInfo [256]uint16 + UVersion uint32 + SzInfoTitle [64]uint16 + DwInfoFlags uint32 + GuidItem [16]byte + HBalloonIcon uintptr +} + +type trayWndClassEx struct { + CbSize uint32 + Style uint32 + LpfnWndProc uintptr + CbClsExtra int32 + CbWndExtra int32 + HInstance uintptr + HIcon uintptr + HCursor uintptr + HbrBackground uintptr + LpszMenuName *uint16 + LpszClassName *uint16 + HIconSm uintptr +} + +type trayPoint struct { + X int32 + Y int32 +} + +type trayMessage struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Point trayPoint +} + +var ( + trayUser32 = syscall.NewLazyDLL("user32.dll") + trayShell32 = syscall.NewLazyDLL("shell32.dll") + trayKernel32 = syscall.NewLazyDLL("kernel32.dll") + procRegisterClassEx = trayUser32.NewProc("RegisterClassExW") + procCreateWindowEx = trayUser32.NewProc("CreateWindowExW") + procDestroyWindow = trayUser32.NewProc("DestroyWindow") + procDefWindowProc = trayUser32.NewProc("DefWindowProcW") + procGetMessage = trayUser32.NewProc("GetMessageW") + procTranslateMsg = trayUser32.NewProc("TranslateMessage") + procDispatchMsg = trayUser32.NewProc("DispatchMessageW") + procPostMessage = trayUser32.NewProc("PostMessageW") + procPostQuitMessage = trayUser32.NewProc("PostQuitMessage") + procLoadIcon = trayUser32.NewProc("LoadIconW") + procDestroyIcon = trayUser32.NewProc("DestroyIcon") + procCreateIcon = trayUser32.NewProc("CreateIconFromResourceEx") + procCreatePopupMenu = trayUser32.NewProc("CreatePopupMenu") + procAppendMenu = trayUser32.NewProc("AppendMenuW") + procDestroyMenu = trayUser32.NewProc("DestroyMenu") + procTrackPopupMenu = trayUser32.NewProc("TrackPopupMenu") + procGetCursorPos = trayUser32.NewProc("GetCursorPos") + procSetForeground = trayUser32.NewProc("SetForegroundWindow") + procShellNotifyIcon = trayShell32.NewProc("Shell_NotifyIconW") + procGetModuleHandle = trayKernel32.NewProc("GetModuleHandleW") + trayWindowProc = syscall.NewCallback(trayWndProc) + trayManagers sync.Map +) + +func newTrayManager(actions trayActions) *trayManager { + return &trayManager{actions: actions} +} + +func (manager *trayManager) Start() error { + manager.mu.Lock() + if manager.done != nil { + manager.mu.Unlock() + return nil + } + ready := make(chan error, 1) + manager.done = make(chan struct{}) + manager.mu.Unlock() + + go manager.run(ready) + if err := <-ready; err != nil { + manager.mu.Lock() + manager.done = nil + manager.mu.Unlock() + return err + } + return nil +} + +func (manager *trayManager) Stop() { + manager.mu.Lock() + hwnd := manager.hwnd + done := manager.done + manager.mu.Unlock() + if done == nil { + return + } + if hwnd != 0 { + postTrayMessage(hwnd, trayShutdownMessage, 0, 0) + } + select { + case <-done: + case <-time.After(1500 * time.Millisecond): + } + manager.mu.Lock() + manager.hwnd = 0 + manager.done = nil + manager.mu.Unlock() +} + +func (manager *trayManager) run(ready chan<- error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + defer close(manager.done) + + instance := getTrayModuleHandle() + className := fmt.Sprintf("CopyTextCompanionTrayWindow-%d", syscall.Getpid()) + if err := registerTrayWindowClass(className, instance); err != nil { + ready <- err + return + } + hwnd, err := createTrayWindow(className, instance) + if err != nil { + ready <- err + return + } + manager.mu.Lock() + manager.hwnd = hwnd + manager.mu.Unlock() + trayManagers.Store(hwnd, manager) + defer trayManagers.Delete(hwnd) + + icon, destroyIcon := createTrayIcon() + if destroyIcon { + defer procDestroyIcon.Call(icon) + } + data := newNotifyIconData(hwnd, icon) + if !shellNotifyIcon(nimAdd, &data) { + _ = destroyTrayWindow(hwnd) + ready <- lastTrayError("Shell_NotifyIconW add failed") + return + } + data.UVersion = notifyIconVersion4 + _ = shellNotifyIcon(nimSetVersion, &data) + ready <- nil + defer shellNotifyIcon(nimDelete, &data) + + var message trayMessage + for { + result, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0) + if result == 0 || int32(result) == -1 { + return + } + procTranslateMsg.Call(uintptr(unsafe.Pointer(&message))) + procDispatchMsg.Call(uintptr(unsafe.Pointer(&message))) + } +} + +func trayWndProc(hwnd uintptr, message uint32, wParam uintptr, lParam uintptr) uintptr { + value, ok := trayManagers.Load(hwnd) + if !ok { + return defTrayWindowProc(hwnd, message, wParam, lParam) + } + return value.(*trayManager).handleWindowMessage(hwnd, message, wParam, lParam) +} + +func (manager *trayManager) handleWindowMessage(hwnd uintptr, message uint32, wParam uintptr, lParam uintptr) uintptr { + switch message { + case trayCallbackMessage: + switch uint32(lParam & 0xffff) { + case wmLButtonUp, wmLButtonDblClk, ninSelect, ninKeySelect: + manager.trigger(manager.actions.Show) + return 0 + case wmRButtonDown, wmRButtonUp, wmContextMenu: + manager.showContextMenu(hwnd) + return 0 + } + case wmCommand: + switch uint16(wParam & 0xffff) { + case idTrayShow: + manager.trigger(manager.actions.Show) + return 0 + case idTrayMinimize: + manager.trigger(manager.actions.Minimize) + return 0 + case idTrayQuit: + manager.trigger(manager.actions.Quit) + return 0 + } + case trayShutdownMessage: + _ = destroyTrayWindow(hwnd) + return 0 + case wmDestroy: + procPostQuitMessage.Call(0) + return 0 + } + return defTrayWindowProc(hwnd, message, wParam, lParam) +} + +func (manager *trayManager) showContextMenu(hwnd uintptr) { + menu, _, _ := procCreatePopupMenu.Call() + if menu == 0 { + return + } + defer procDestroyMenu.Call(menu) + appendTrayMenu(menu, mfString, idTrayShow, "Show") + appendTrayMenu(menu, mfString, idTrayMinimize, "Minimize") + appendTrayMenu(menu, mfSeparator, 0, "") + appendTrayMenu(menu, mfString, idTrayQuit, "Quit") + + var point trayPoint + procGetCursorPos.Call(uintptr(unsafe.Pointer(&point))) + procSetForeground.Call(hwnd) + command, _, _ := procTrackPopupMenu.Call( + menu, + tpmRightButton|tpmReturnCmd, + uintptr(point.X), + uintptr(point.Y), + 0, + hwnd, + 0, + ) + if command != 0 { + manager.handleWindowMessage(hwnd, wmCommand, command, 0) + } + postTrayMessage(hwnd, wmNull, 0, 0) +} + +func (manager *trayManager) trigger(action func() bool) { + if action == nil { + return + } + go action() +} + +func registerTrayWindowClass(className string, instance uintptr) error { + classNamePtr, err := syscall.UTF16PtrFromString(className) + if err != nil { + return err + } + wndClass := trayWndClassEx{ + CbSize: uint32(unsafe.Sizeof(trayWndClassEx{})), + Style: csDblClks, + LpfnWndProc: trayWindowProc, + HInstance: instance, + LpszClassName: classNamePtr, + } + result, _, callErr := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wndClass))) + if result == 0 && !errors.Is(callErr, errorClassAlreadyExists) { + return lastTrayError("RegisterClassExW failed") + } + return nil +} + +func createTrayWindow(className string, instance uintptr) (uintptr, error) { + classNamePtr, err := syscall.UTF16PtrFromString(className) + if err != nil { + return 0, err + } + hwnd, _, _ := procCreateWindowEx.Call( + 0, + uintptr(unsafe.Pointer(classNamePtr)), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + instance, + 0, + ) + if hwnd == 0 { + return 0, lastTrayError("CreateWindowExW failed") + } + return hwnd, nil +} + +func destroyTrayWindow(hwnd uintptr) bool { + result, _, _ := procDestroyWindow.Call(hwnd) + return result != 0 +} + +func createTrayIcon() (uintptr, bool) { + if len(trayIconPNG) > 0 { + icon, _, _ := procCreateIcon.Call( + uintptr(unsafe.Pointer(&trayIconPNG[0])), + uintptr(len(trayIconPNG)), + 1, + 0x00030000, + 0, + 0, + lrDefaultSize, + ) + if icon != 0 { + return icon, true + } + } + icon, _, _ := procLoadIcon.Call(0, idiApplication) + return icon, false +} + +func newNotifyIconData(hwnd uintptr, icon uintptr) notifyIconData { + data := notifyIconData{ + CbSize: uint32(unsafe.Sizeof(notifyIconData{})), + HWnd: hwnd, + UID: trayUID, + UFlags: nifMessage | nifIcon | nifTip, + UCallbackMessage: trayCallbackMessage, + HIcon: icon, + } + copy(data.SzTip[:], syscall.StringToUTF16("Copy Text Companion")) + return data +} + +func shellNotifyIcon(command uintptr, data *notifyIconData) bool { + result, _, _ := procShellNotifyIcon.Call(command, uintptr(unsafe.Pointer(data))) + return result != 0 +} + +func appendTrayMenu(menu uintptr, flags uintptr, id uintptr, text string) { + if flags == mfSeparator { + procAppendMenu.Call(menu, flags, 0, 0) + return + } + procAppendMenu.Call(menu, flags, id, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(text)))) +} + +func postTrayMessage(hwnd uintptr, message uint32, wParam uintptr, lParam uintptr) { + procPostMessage.Call(hwnd, uintptr(message), wParam, lParam) +} + +func defTrayWindowProc(hwnd uintptr, message uint32, wParam uintptr, lParam uintptr) uintptr { + result, _, _ := procDefWindowProc.Call(hwnd, uintptr(message), wParam, lParam) + return result +} + +func getTrayModuleHandle() uintptr { + result, _, _ := procGetModuleHandle.Call(0) + return result +} + +func lastTrayError(prefix string) error { + err := syscall.GetLastError() + if err == syscall.Errno(0) { + return errors.New(prefix) + } + return fmt.Errorf("%s: %w", prefix, err) +} diff --git a/companion/wails.json b/companion/wails.json new file mode 100644 index 0000000..a851e52 --- /dev/null +++ b/companion/wails.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "CopyTextCompanion", + "outputfilename": "copy-text-companion", + "frontend:install": "", + "frontend:build": "", + "frontend:dev:watcher": "", + "author": { + "name": "copy-text-without-selecting" + }, + "info": { + "productName": "Copy Text Companion", + "companyName": "copy-text-without-selecting", + "comments": "Native clipboard companion for Copy Text Without Selecting" + } +} diff --git a/docs/release-companion-checklist.md b/docs/release-companion-checklist.md new file mode 100644 index 0000000..39051d7 --- /dev/null +++ b/docs/release-companion-checklist.md @@ -0,0 +1,79 @@ +# Companion MVP Release Checklist + +## Build + +- `npm run validate` +- `npm run test:e2e` +- `npm run native:build` +- `npm run native:test-host` +- `npm run native:test-tray` from an interactive Windows desktop. +- Stop any running `copy-text-companion.exe` process if `native:install-host` + cannot replace the installed executable. +- `$env:COPY_TEXT_DEV_EXTENSION_ID = ""` +- `npm run native:install-host` +- `npm run test:e2e:native` +- `git diff --check` +- Load `dist/chrome/` as an unpacked extension in Chrome. +- For unpacked testing, rerun install with `COPY_TEXT_DEV_EXTENSION_ID` set to + the unpacked extension ID shown by Chrome. + +## Smoke Test + +- Popup shows `Companion: connected`. +- `Open app` opens the companion window. +- Copy text from a web page and confirm the item appears in the companion. +- Copy JSON from `fixtures/native-companion.html` and confirm native history + stores it with `format: "json"`. +- Copy a companion history item back to the clipboard. +- Search filters companion history by text/source/domain. +- Smart preview works for JSON, SQL, JWT, timestamp, and Base64 samples. +- Pin, unpin, delete, and clear update the local JSON store correctly; after + clear, `history` remains an empty array, not `null`. +- With the host removed, copy two fixture items, pin one, and delete the other. + Restart Chrome with the same profile, reinstall the host, and confirm the + pinned item reaches Companion while the deleted item does not. +- Remove the host again, clear local history, copy a new item, restart Chrome, + and reinstall the host. Confirm the new item remains and no item older than + the clear is resurrected. +- Confirm pending outbox status reaches zero only after Companion acknowledges + every queued operation. The primary outbox is IndexedDB; legacy + `copyHistorySyncOutbox` is only a migration/fallback path. +- Minimize restores via the configured global hotkey. +- App menu quit exits the companion process. +- Tray icon is visible while the companion is running. +- Tray left-click or double-click opens the companion when it is minimized. +- Tray context menu supports Show, Minimize, and Quit. +- `npm run native:test-tray` verifies Shell tray registration and tray actions + with real mouse/UI Automation calls when input injection is available. + Restricted desktop sessions use Win32 message injection after checking the + real icon rectangle and menu labels. Still do one visual notification-area + pass when the fallback path is reported or Windows hides the icon in overflow. +- Toggle auto-start on and off; verify the + `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\CopyTextCompanion` + value is restored to its original state after the test. + +## Fallback + +- Run `npm run native:uninstall-host`. +- Reload the unpacked extension and confirm the popup shows + `Companion: local mode`. +- Copy from the fixture again and confirm extension local history still works. +- Confirm pending outbox operations remain queued while the host is unavailable + instead of being discarded. In current Chrome this queue is IndexedDB-backed; + `chrome.storage.local` should only hold the local history cache and any + legacy migration fallback. +- Reinstall the host with the unpacked extension ID before continuing connected + smoke or leaving the developer machine in a ready state. + +## Permission Disclosure + +- `nativeMessaging` is used only to connect to the local Windows companion. +- Clipboard/history data is stored locally under `%APPDATA%\CopyTextWithoutSelecting`. +- The extension still works in local mode when the companion is absent. + +## Uninstall + +- Run `npm run native:uninstall-host`. +- Confirm the Chrome native host registry key is removed from + `HKCU\Software\Google\Chrome\NativeMessagingHosts` and + `HKCU\Software\Chromium\NativeMessagingHosts`. diff --git a/fixtures/native-companion.html b/fixtures/native-companion.html new file mode 100644 index 0000000..bb7ed6a --- /dev/null +++ b/fixtures/native-companion.html @@ -0,0 +1,17 @@ + + + + + + Native Companion Fixture + + +
+

Native companion integration

+
{"integration":"copy-text-native","value":42}
+
offline history item to pin
+
offline history item to delete
+
history item created after offline clear
+
+ + diff --git a/manifest.json b/manifest.json index 324804d..7caa4aa 100644 --- a/manifest.json +++ b/manifest.json @@ -10,6 +10,7 @@ "scripting", "storage", "clipboardWrite", + "nativeMessaging", "alarms" ], "host_permissions": [ diff --git a/options.css b/options.css index d61f059..267ea3a 100644 --- a/options.css +++ b/options.css @@ -536,6 +536,24 @@ input[type="range"] { background: var(--surface-raised); } +.history-chip[class*="format-"] { + color: var(--accent-strong); + border-color: rgba(14, 165, 233, 0.28); + background: rgba(14, 165, 233, 0.1); +} + +.history-smart-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.smart-action-button { + padding: 7px 10px; + border-radius: 10px; + font-size: 12px; +} + .history-fulltext { font-size: 12px; line-height: 1.6; diff --git a/package.json b/package.json index 222d18d..a520948 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,17 @@ "scripts": { "test": "node --test", "test:e2e": "npm run build:chrome && playwright test", + "test:e2e:native": "node scripts/e2e-native-companion.cjs", "check:syntax": "node scripts/check-syntax.cjs", "check:manifest": "node scripts/check-manifest.cjs", "validate": "npm run test && npm run check:syntax && npm run check:manifest && npm run build:chrome", "build:chrome": "node scripts/build-chrome.cjs", + "native:test": "node scripts/check-companion.cjs", + "native:build": "node scripts/build-companion.cjs", + "native:test-host": "node scripts/smoke-native-host.cjs", + "native:test-tray": "node scripts/smoke-tray-windows.cjs", + "native:install-host": "node scripts/install-native-host-windows.cjs", + "native:uninstall-host": "node scripts/uninstall-native-host-windows.cjs", "pack:chrome": "node scripts/pack-chrome.cjs", "verify:release": "npm run validate && npm run test:e2e && npm run pack:chrome" }, diff --git a/popup.css b/popup.css index 55409bf..1ee1159 100644 --- a/popup.css +++ b/popup.css @@ -186,6 +186,29 @@ color: var(--badge-mute-fg); } + .native-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + } + + .native-status { + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--border-item); + background: var(--surface-item); + color: var(--muted); + font-size: 12px; + font-weight: 650; + } + + .native-status.connected { + color: var(--badge-ok-fg); + border-color: var(--accent-border); + background: var(--accent-surface); + } + .control { width: 100%; font: inherit; @@ -375,6 +398,25 @@ flex-wrap: wrap; } + .history-smart-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + padding-top: 2px; + } + + .smart-action-button { + font-size: 11px; + padding: 6px 8px; + border-radius: 9px; + } + + .history-chip[class*="format-"] { + color: var(--accent-alt); + border-color: rgba(14, 165, 233, 0.28); + background: rgba(14, 165, 233, 0.1); + } + .button.small { padding: 7px 10px; border-radius: 10px; diff --git a/popup.html b/popup.html index 2da411f..d673133 100644 --- a/popup.html +++ b/popup.html @@ -23,6 +23,10 @@
Unsupported +
+
Companion: local mode
+ +
diff --git a/scripts/build-companion.cjs b/scripts/build-companion.cjs new file mode 100644 index 0000000..dd9b878 --- /dev/null +++ b/scripts/build-companion.cjs @@ -0,0 +1,79 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { resolveGo, resolveWails } = require("./lib/native-tools.cjs"); + +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const COMPANION_ROOT = path.join(PROJECT_ROOT, "companion"); +const OUTPUT_DIR = path.join(PROJECT_ROOT, "dist", "native"); +const OUTPUT_EXE = path.join(OUTPUT_DIR, "copy-text-companion.exe"); +const WAILS_OUTPUT_EXE = path.join(COMPANION_ROOT, "build", "bin", "copy-text-companion.exe"); + +function main() { + const go = resolveGo(); + const wails = resolveWails(); + const toolEnv = createToolEnv(go, wails); + const goVersion = spawnSync(go, ["version"], { encoding: "utf8" }); + if (goVersion.error || goVersion.status !== 0) { + throw new Error("Go is required to build the companion native host."); + } + + const testResult = spawnSync(go, ["test", "./..."], { + cwd: COMPANION_ROOT, + env: toolEnv, + stdio: "inherit", + }); + if (testResult.status !== 0) { + throw new Error("Companion tests failed."); + } + + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const wailsVersion = spawnSync(wails, ["version"], { encoding: "utf8", env: toolEnv }); + let result; + if (!wailsVersion.error && wailsVersion.status === 0) { + result = spawnSync(wails, ["build", "-clean"], { + cwd: COMPANION_ROOT, + env: toolEnv, + stdio: "inherit", + }); + if (result.status === 0 && fs.existsSync(WAILS_OUTPUT_EXE)) { + fs.copyFileSync(WAILS_OUTPUT_EXE, OUTPUT_EXE); + } + } else { + console.warn("Wails CLI was not found; falling back to go build."); + result = spawnSync(go, ["build", "-o", OUTPUT_EXE, "."], { + cwd: COMPANION_ROOT, + env: toolEnv, + stdio: "inherit", + }); + } + + if (result.status !== 0) { + throw new Error("Companion build failed."); + } + + console.log(`Companion build complete: ${OUTPUT_EXE}`); +} + +function createToolEnv(go, wails) { + const additions = []; + for (const tool of [go, wails]) { + if (tool && path.isAbsolute(tool)) { + additions.push(path.dirname(tool)); + } + } + return Object.assign({}, process.env, { + Path: additions.concat(process.env.Path || process.env.PATH || "").join(path.delimiter), + }); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message || error); + process.exitCode = 1; + } +} + +module.exports = { main }; diff --git a/scripts/check-companion.cjs b/scripts/check-companion.cjs new file mode 100644 index 0000000..430cf98 --- /dev/null +++ b/scripts/check-companion.cjs @@ -0,0 +1,33 @@ +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); +const { resolveGo } = require("./lib/native-tools.cjs"); + +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const COMPANION_ROOT = path.join(PROJECT_ROOT, "companion"); + +function main() { + const go = resolveGo(); + const goVersion = spawnSync(go, ["version"], { encoding: "utf8" }); + if (goVersion.error || goVersion.status !== 0) { + throw new Error("Go is required to test the companion."); + } + + const result = spawnSync(go, ["test", "./..."], { + cwd: COMPANION_ROOT, + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error("Companion tests failed."); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message || error); + process.exitCode = 1; + } +} + +module.exports = { main }; diff --git a/scripts/e2e-native-companion.cjs b/scripts/e2e-native-companion.cjs new file mode 100644 index 0000000..0ec9d79 --- /dev/null +++ b/scripts/e2e-native-companion.cjs @@ -0,0 +1,639 @@ +const fs = require("node:fs"); +const http = require("node:http"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { chromium } = require("@playwright/test"); + +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const EXTENSION_ROOT = path.join(PROJECT_ROOT, "dist", "chrome"); +const BROWSER_EXE = chromium.executablePath(); +const INSTALL_SCRIPT = path.join(PROJECT_ROOT, "scripts", "install-native-host-windows.cjs"); +const UNINSTALL_SCRIPT = path.join(PROJECT_ROOT, "scripts", "uninstall-native-host-windows.cjs"); +const nativeInstall = require("./install-native-host-windows.cjs"); +const NATIVE_EXE = path.join(PROJECT_ROOT, "dist", "native", "copy-text-companion.exe"); +const HOST = "127.0.0.1"; +const JSON_TEXT = "{\"integration\":\"copy-text-native\",\"value\":42}"; +const OFFLINE_KEEP_TEXT = "offline history item to pin"; +const OFFLINE_DELETE_TEXT = "offline history item to delete"; +const AFTER_CLEAR_TEXT = "history item created after offline clear"; +let fixtureUrl = ""; + +async function main() { + assertFile(BROWSER_EXE, "Playwright Chromium"); + assertFile(NATIVE_EXE, "Native companion"); + assertFile(path.join(EXTENSION_ROOT, "manifest.json"), "Built extension"); + + const nativeState = captureNativeHostState(); + const server = await startFixtureServer(); + const appData = fs.mkdtempSync(path.join(os.tmpdir(), "copy-text-native-e2e-appdata-")); + const nativeInstallRoot = fs.mkdtempSync(path.join(os.tmpdir(), "copy-text-native-e2e-install-")); + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "copy-text-native-e2e-profile-")); + let extensionId = ""; + let session = null; + const cleanupErrors = []; + + try { + console.log("Launching Playwright Chromium with the unpacked extension..."); + session = await launchExtension(appData, userDataDir); + extensionId = session.extensionId; + console.log(`Detected unpacked extension ID: ${extensionId}`); + installHost(extensionId, nativeInstallRoot); + + console.log("Verifying connected native companion flow..."); + await verifyConnectedFlow(session.context); + await closeSession(session); + session = null; + + console.log("Removing the native host to queue offline history operations..."); + uninstallHost(nativeInstallRoot); + await delay(400); + + session = await launchExtension(appData, userDataDir); + await verifyOfflinePinDeleteQueue(session.context); + await closeSession(session); + session = null; + + console.log("Reinstalling the native host to flush offline pin/delete operations..."); + installHost(extensionId, nativeInstallRoot); + session = await launchExtension(appData, userDataDir); + await verifyPinDeleteReconnect(session.context); + await closeSession(session); + session = null; + + console.log("Removing the native host to queue clear followed by a new copy..."); + uninstallHost(nativeInstallRoot); + await delay(400); + session = await launchExtension(appData, userDataDir); + await verifyOfflineClearQueue(session.context); + await closeSession(session); + session = null; + + console.log("Reinstalling the native host to verify clear ordering..."); + installHost(extensionId, nativeInstallRoot); + session = await launchExtension(appData, userDataDir); + await verifyClearReconnect(session.context); + await closeSession(session); + session = null; + + console.log("Removing the native host for the final local fallback check..."); + uninstallHost(nativeInstallRoot); + await delay(400); + session = await launchExtension(appData, userDataDir); + await verifyLocalFallback(session.context, AFTER_CLEAR_TEXT); + } finally { + await cleanupStep("browser session", async function () { + if (session) await closeSession(session); + }, cleanupErrors); + await cleanupStep("fixture server", function () { + return stopServer(server); + }, cleanupErrors); + await cleanupStep("native host registration", function () { + restoreNativeHostState(nativeState); + }, cleanupErrors); + await cleanupStep("temporary native install", function () { + fs.rmSync(nativeInstallRoot, { recursive: true, force: true }); + }, cleanupErrors); + await cleanupStep("temporary app data", function () { + fs.rmSync(appData, { recursive: true, force: true }); + }, cleanupErrors); + await cleanupStep("Playwright profile", function () { + fs.rmSync(userDataDir, { recursive: true, force: true }); + }, cleanupErrors); + } + + if (cleanupErrors.length) { + throw new Error(formatCleanupErrors(cleanupErrors)); + } + + console.log(`Native Chrome integration passed for extension ${extensionId}.`); +} + +async function launchExtension(appData, userDataDir) { + let context = null; + try { + context = await chromium.launchPersistentContext(userDataDir, { + executablePath: BROWSER_EXE, + headless: true, + env: Object.assign({}, process.env, { APPDATA: appData }), + args: [ + `--disable-extensions-except=${EXTENSION_ROOT}`, + `--load-extension=${EXTENSION_ROOT}`, + ], + }); + + await context.grantPermissions(["clipboard-read", "clipboard-write"], { + origin: new URL(fixtureUrl).origin, + }); + + let serviceWorker = context.serviceWorkers()[0]; + if (!serviceWorker) { + serviceWorker = await context.waitForEvent("serviceworker", { timeout: 15000 }); + } + + return { + context, + extensionId: new URL(serviceWorker.url()).host, + }; + } catch (error) { + if (context) { + await context.close().catch(function () {}); + } + throw error; + } +} + +async function closeSession(session) { + await session.context.close(); + await delay(250); +} + +async function verifyConnectedFlow(context) { + const extensionId = new URL(context.serviceWorkers()[0].url()).host; + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup.html`); + await waitFor(async function () { + return (await popup.locator("#native_status").textContent()) === "Companion: connected"; + }, "popup to report Companion: connected"); + console.log("Popup reports Companion: connected."); + + await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_CLEAR"); + + const fixture = await context.newPage(); + await fixture.goto(fixtureUrl); + await fixture.locator("#json_payload").click({ modifiers: ["Alt"] }); + + const listResponse = await waitFor(async function () { + const response = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { limit: 10 }); + const history = response && response.ok && response.payload && response.payload.history; + if (Array.isArray(history) && history.some(function (entry) { return entry.text === JSON_TEXT; })) { + return response; + } + return null; + }, "native history to receive the copied JSON"); + + const entry = listResponse.payload.history.find(function (item) { + return item.text === JSON_TEXT; + }); + if (!entry || entry.format !== "json") { + throw new Error(`Expected a JSON history entry, got ${JSON.stringify(entry)}`); + } + console.log("JSON copy reached native history."); + + const search = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { + query: "copy-text-native", + limit: 10, + }); + assertHistoryCount(search, 1, "history search"); + console.log("Native history search passed."); + + const preview = await nativeMessage(popup, "COPY_TEXT_NATIVE_FORMAT_PREVIEW", { + text: JSON_TEXT, + action: "prettyJson", + }); + if (!preview || !preview.ok || !preview.payload || !preview.payload.text.includes("\n \"integration\"")) { + throw new Error(`Expected pretty JSON preview, got ${JSON.stringify(preview)}`); + } + console.log("Smart JSON preview passed."); + + const pinned = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_PIN", { + id: entry.id, + pinned: true, + }); + if (!pinned || !pinned.ok || !pinned.payload.changed) { + throw new Error(`Expected history pin to change the entry, got ${JSON.stringify(pinned)}`); + } + + const pinnedList = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { limit: 10 }); + if (!pinnedList.payload.history[0].pinned) { + throw new Error("Expected pinned entry to sort first."); + } + + const unpinned = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_PIN", { + id: entry.id, + pinned: false, + }); + if (!unpinned || !unpinned.ok || !unpinned.payload.changed) { + throw new Error(`Expected history unpin to change the entry, got ${JSON.stringify(unpinned)}`); + } + const unpinnedList = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { limit: 10 }); + if (unpinnedList.payload.history[0].pinned) { + throw new Error("Expected history entry to be unpinned."); + } + console.log("Pin, unpin, and ordering passed."); + + const replayButton = popup.locator(".history-item", { hasText: "copy-text-native" }).locator("button").first(); + await replayButton.click(); + await waitFor(async function () { + return fixture.evaluate(function () { + return navigator.clipboard.readText(); + }).then(function (text) { + return text === JSON_TEXT; + }); + }, "history replay to copy JSON back to the clipboard"); + console.log("History replay copied JSON back to the clipboard."); + + await waitFor(async function () { + const replayed = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { limit: 10 }); + const replayedEntry = replayed && replayed.ok && replayed.payload.history.find(function (item) { + return item.text === JSON_TEXT; + }); + return replayedEntry && replayedEntry.source === "history"; + }, "history replay metadata to synchronize"); + + const deleted = await nativeMessage(popup, "COPY_TEXT_LOCAL_HISTORY_DELETE", { + id: entry.id, + text: entry.text, + }); + if (!deleted || !deleted.ok) { + throw new Error(`Expected history delete to remove the entry, got ${JSON.stringify(deleted)}`); + } + assertHistoryCount(await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { limit: 10 }), 0, "history delete"); + console.log("Native history delete passed."); + + await fixture.locator("#json_payload").click({ modifiers: ["Alt"] }); + await waitFor(async function () { + const response = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { limit: 10 }); + return response && response.ok && response.payload.history.length === 1; + }, "history to refill before clear"); + + const cleared = await nativeMessage(popup, "COPY_TEXT_LOCAL_HISTORY_CLEAR"); + if (!cleared || !cleared.ok) { + throw new Error(`Expected history clear to remove items, got ${JSON.stringify(cleared)}`); + } + assertHistoryCount(await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { limit: 10 }), 0, "history clear"); + console.log("Native history clear passed."); + + await fixture.close(); + await popup.close(); +} + +async function verifyOfflinePinDeleteQueue(context) { + const extensionId = new URL(context.serviceWorkers()[0].url()).host; + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup.html`); + await waitFor(async function () { + return (await popup.locator("#native_status").textContent()) === "Companion: local mode"; + }, "popup to report Companion: local mode"); + console.log("Popup reports Companion: local mode."); + + const fixture = await context.newPage(); + await fixture.goto(fixtureUrl); + await fixture.locator("#offline_keep").click({ modifiers: ["Alt"] }); + await fixture.locator("#offline_delete").click({ modifiers: ["Alt"] }); + + const localList = await waitFor(async function () { + const response = await nativeMessage(popup, "COPY_TEXT_LOCAL_HISTORY_LIST", { limit: 20 }); + const history = response && response.ok && response.payload && response.payload.history; + if (response && response.payload && response.payload.mode === "local" + && Array.isArray(history) + && history.some(function (entry) { return entry.text === OFFLINE_KEEP_TEXT; }) + && history.some(function (entry) { return entry.text === OFFLINE_DELETE_TEXT; })) { + return response; + } + return null; + }, "offline copies to reach local history", 12000); + + const keepEntry = localList.payload.history.find(function (entry) { + return entry.text === OFFLINE_KEEP_TEXT; + }); + const deleteEntry = localList.payload.history.find(function (entry) { + return entry.text === OFFLINE_DELETE_TEXT; + }); + await nativeMessage(popup, "COPY_TEXT_LOCAL_HISTORY_PIN", { + id: keepEntry.id, + text: keepEntry.text, + pinned: true, + }); + await nativeMessage(popup, "COPY_TEXT_LOCAL_HISTORY_DELETE", { + id: deleteEntry.id, + text: deleteEntry.text, + }); + + const queued = await popup.evaluate(async function () { + return chrome.storage.local.get({ copyHistory: [] }); + }); + const outbox = await nativeMessage(popup, "COPY_TEXT_LOCAL_HISTORY_OUTBOX_STATUS"); + if (!queued.copyHistory.some(function (entry) { + return entry.text === OFFLINE_KEEP_TEXT && entry.pinned; + })) { + throw new Error(`Expected offline pin in local history, got ${JSON.stringify(queued.copyHistory)}`); + } + if (queued.copyHistory.some(function (entry) { return entry.text === OFFLINE_DELETE_TEXT; })) { + throw new Error("Expected offline delete to remove the local item."); + } + if (!outbox || !outbox.ok || outbox.payload.pending < 4) { + throw new Error(`Expected pending add/pin/delete operations, got ${JSON.stringify(outbox)}`); + } + console.log("Offline copy, pin, and delete operations were queued."); + + await fixture.close(); + await popup.close(); +} + +async function verifyPinDeleteReconnect(context) { + const popup = await openConnectedPopup(context); + const response = await waitFor(async function () { + const listed = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { limit: 20 }); + const history = listed && listed.ok && listed.payload && listed.payload.history; + if (!Array.isArray(history)) { + return null; + } + const keep = history.find(function (entry) { return entry.text === OFFLINE_KEEP_TEXT; }); + const removed = history.find(function (entry) { return entry.text === OFFLINE_DELETE_TEXT; }); + return keep && keep.pinned && !removed ? listed : null; + }, "offline pin/delete operations to flush after reconnect", 12000); + if (!response.payload.history.some(function (entry) { + return entry.text === OFFLINE_KEEP_TEXT && entry.pinned; + })) { + throw new Error(`Expected pinned offline item after reconnect, got ${JSON.stringify(response)}`); + } + await assertOutboxEmpty(popup, "pin/delete reconnect"); + console.log("Offline add, pin, and delete synchronized after reconnect."); + await popup.close(); +} + +async function verifyOfflineClearQueue(context) { + const extensionId = new URL(context.serviceWorkers()[0].url()).host; + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup.html`); + await waitFor(async function () { + return (await popup.locator("#native_status").textContent()) === "Companion: local mode"; + }, "popup to report Companion: local mode before clear"); + + await nativeMessage(popup, "COPY_TEXT_LOCAL_HISTORY_CLEAR"); + const fixture = await context.newPage(); + await fixture.goto(fixtureUrl); + await fixture.locator("#after_clear").click({ modifiers: ["Alt"] }); + + await waitFor(async function () { + const stored = await popup.evaluate(async function () { + return chrome.storage.local.get({ copyHistory: [] }); + }); + const outbox = await nativeMessage(popup, "COPY_TEXT_LOCAL_HISTORY_OUTBOX_STATUS"); + const operations = outbox && outbox.ok && outbox.payload && outbox.payload.operations; + return stored.copyHistory.length === 1 + && stored.copyHistory[0].text === AFTER_CLEAR_TEXT + && Array.isArray(operations) + && operations.length === 2 + && operations[0].type === "clear" + && operations[1].type === "upsert"; + }, "clear and later copy to remain ordered in the outbox", 12000); + console.log("Offline clear retained only the clear and subsequent copy operations."); + + await fixture.close(); + await popup.close(); +} + +async function verifyClearReconnect(context) { + const popup = await openConnectedPopup(context); + const response = await waitFor(async function () { + const listed = await nativeMessage(popup, "COPY_TEXT_NATIVE_HISTORY_LIST", { limit: 20 }); + const history = listed && listed.ok && listed.payload && listed.payload.history; + return Array.isArray(history) + && history.length === 1 + && history[0].text === AFTER_CLEAR_TEXT + ? listed + : null; + }, "offline clear to flush before its subsequent copy", 12000); + assertHistoryCount(response, 1, "clear ordering"); + await assertOutboxEmpty(popup, "clear reconnect"); + console.log("Offline clear did not resurrect older history after reconnect."); + await popup.close(); +} + +async function verifyLocalFallback(context, expectedText) { + const extensionId = new URL(context.serviceWorkers()[0].url()).host; + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup.html`); + await waitFor(async function () { + return (await popup.locator("#native_status").textContent()) === "Companion: local mode"; + }, "popup to report Companion: local mode"); + + const localList = await nativeMessage(popup, "COPY_TEXT_LOCAL_HISTORY_LIST", { limit: 20 }); + if (!localList || !localList.ok || localList.payload.mode !== "local" + || !localList.payload.history.some(function (entry) { return entry.text === expectedText; })) { + throw new Error(`Expected local fallback history to contain ${expectedText}, got ${JSON.stringify(localList)}`); + } + console.log("Extension local history remains functional."); + await popup.close(); +} + +async function openConnectedPopup(context) { + const extensionId = new URL(context.serviceWorkers()[0].url()).host; + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup.html`); + await waitFor(async function () { + return (await popup.locator("#native_status").textContent()) === "Companion: connected"; + }, "popup to report Companion: connected", 12000); + return popup; +} + +async function assertOutboxEmpty(page, label) { + await waitFor(async function () { + const response = await nativeMessage(page, "COPY_TEXT_LOCAL_HISTORY_OUTBOX_STATUS"); + return response && response.ok && response.payload && response.payload.pending === 0; + }, `${label} outbox to empty`, 12000); +} + +function nativeMessage(page, type, payload) { + return page.evaluate(async function (message) { + return chrome.runtime.sendMessage(message); + }, { type, payload: payload || {} }); +} + +function assertHistoryCount(response, count, label) { + const history = response && response.ok && response.payload && response.payload.history; + if (!Array.isArray(history) || history.length !== count) { + throw new Error(`${label} expected ${count} item(s), got ${JSON.stringify(response)}`); + } +} + +function installHost(extensionId, nativeInstallRoot) { + const env = Object.assign({}, process.env); + if (extensionId) { + env.COPY_TEXT_DEV_EXTENSION_ID = extensionId; + } + if (nativeInstallRoot) { + env.COPY_TEXT_NATIVE_INSTALL_ROOT = nativeInstallRoot; + } + const result = spawnSync(process.execPath, [INSTALL_SCRIPT], { + cwd: PROJECT_ROOT, + env, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "Native host installation failed.").trim()); + } +} + +function uninstallHost(nativeInstallRoot) { + const env = Object.assign({}, process.env); + if (nativeInstallRoot) { + env.COPY_TEXT_NATIVE_INSTALL_ROOT = nativeInstallRoot; + } + const result = spawnSync(process.execPath, [UNINSTALL_SCRIPT], { + cwd: PROJECT_ROOT, + env, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "Native host removal failed.").trim()); + } +} + +function assertFile(filePath, label) { + if (!fs.existsSync(filePath)) { + throw new Error(`${label} is missing: ${filePath}`); + } +} + +function waitFor(check, label, timeoutMs = 8000) { + const startedAt = Date.now(); + return new Promise(function (resolve, reject) { + async function poll() { + try { + const value = await check(); + if (value) { + resolve(value); + return; + } + } catch (error) { + if (Date.now() - startedAt >= timeoutMs) { + reject(error); + return; + } + } + + if (Date.now() - startedAt >= timeoutMs) { + reject(new Error(`Timed out waiting for ${label}.`)); + return; + } + setTimeout(poll, 150); + } + poll(); + }); +} + +function startFixtureServer() { + const fixtureRoot = path.join(PROJECT_ROOT, "fixtures"); + const server = http.createServer(function (request, response) { + const requestPath = new URL(request.url, fixtureUrl || `http://${HOST}/`).pathname; + const relativePath = requestPath.replace(/^\/fixtures\//, ""); + const absolutePath = path.join(fixtureRoot, relativePath); + const relativeToRoot = path.relative(fixtureRoot, absolutePath); + if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) { + response.statusCode = 404; + response.end("Not found"); + return; + } + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.end(fs.readFileSync(absolutePath)); + }); + + return new Promise(function (resolve, reject) { + server.once("error", reject); + server.listen(0, HOST, function () { + const address = server.address(); + fixtureUrl = `http://${HOST}:${address.port}/fixtures/native-companion.html`; + resolve(server); + }); + }); +} + +function stopServer(server) { + return new Promise(function (resolve) { + server.close(resolve); + }); +} + +function delay(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, ms); + }); +} + +function captureNativeHostState() { + const manifestPath = path.join(nativeInstall.INSTALL_ROOT, "NativeMessagingHosts", `${nativeInstall.HOST_NAME}.json`); + return { + exe: readOptionalFile(nativeInstall.INSTALLED_HOST_EXE), + manifest: readOptionalFile(manifestPath), + manifestPath, + registry: nativeInstall.REGISTRY_PATHS.map(function (registryPath) { + return { path: registryPath, value: nativeInstall.readRegistryDefault(registryPath) }; + }), + }; +} + +function restoreNativeHostState(state) { + const restoreErrors = []; + try { + restoreOptionalFile(nativeInstall.INSTALLED_HOST_EXE, state.exe); + } catch (error) { + restoreErrors.push(`restore ${nativeInstall.INSTALLED_HOST_EXE}: ${error.message || error}`); + } + try { + restoreOptionalFile(state.manifestPath, state.manifest); + } catch (error) { + restoreErrors.push(`restore ${state.manifestPath}: ${error.message || error}`); + } + state.registry.forEach(function (entry) { + if (entry.value === null) { + const result = spawnSync("reg", ["delete", entry.path, "/f"], { encoding: "utf8" }); + if (result.status !== 0 && !/unable to find|cannot find/i.test(result.stderr + result.stdout)) { + restoreErrors.push((result.stderr || result.stdout || `Failed to delete ${entry.path}`).trim()); + } + return; + } + const result = spawnSync("reg", ["add", entry.path, "/ve", "/t", "REG_SZ", "/d", entry.value, "/f"], { encoding: "utf8" }); + if (result.status !== 0) { + restoreErrors.push((result.stderr || result.stdout || `Failed to restore ${entry.path}`).trim()); + } + }); + if (restoreErrors.length) { + throw new Error(restoreErrors.join("; ")); + } +} + +function readOptionalFile(filePath) { + return fs.existsSync(filePath) ? fs.readFileSync(filePath) : null; +} + +function restoreOptionalFile(filePath, contents) { + if (contents === null) { + fs.rmSync(filePath, { force: true }); + return; + } + if (fs.existsSync(filePath) && Buffer.compare(fs.readFileSync(filePath), contents) === 0) { + return; + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); +} + +async function cleanupStep(label, action, cleanupErrors) { + try { + await action(); + } catch (error) { + if (Array.isArray(cleanupErrors)) { + cleanupErrors.push({ label, error }); + } + console.warn(`Cleanup failed for ${label}: ${error && error.message ? error.message : error}`); + } +} + +function formatCleanupErrors(cleanupErrors) { + return "Native E2E cleanup failed: " + cleanupErrors.map(function (item) { + const message = item.error && item.error.message ? item.error.message : String(item.error); + return `${item.label}: ${message}`; + }).join("; "); +} + +if (require.main === module) { + main().catch(function (error) { + console.error(error.stack || error.message || error); + process.exitCode = 1; + }); +} + +module.exports = { main }; diff --git a/scripts/install-native-host-windows.cjs b/scripts/install-native-host-windows.cjs new file mode 100644 index 0000000..fa31082 --- /dev/null +++ b/scripts/install-native-host-windows.cjs @@ -0,0 +1,176 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const HOST_NAME = "com.copy_text_without_selecting.companion"; +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const HOST_EXE = path.join(PROJECT_ROOT, "dist", "native", "copy-text-companion.exe"); +const INSTALL_ROOT = process.env.COPY_TEXT_NATIVE_INSTALL_ROOT + ? path.resolve(process.env.COPY_TEXT_NATIVE_INSTALL_ROOT) + : path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "CopyTextWithoutSelecting"); +const INSTALLED_HOST_EXE = path.join(INSTALL_ROOT, "copy-text-companion.exe"); +const DEFAULT_PRODUCTION_EXTENSION_ID = "obhagoegpnbklgknnmbglghkfdidegkl"; +const REGISTRY_PATHS = [ + `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`, + `HKCU\\Software\\Chromium\\NativeMessagingHosts\\${HOST_NAME}`, +]; + +function main() { + if (process.platform !== "win32") { + throw new Error("Native host registry installation is Windows-only."); + } + + if (!fs.existsSync(HOST_EXE)) { + throw new Error(`Native host executable is missing. Run npm run native:build first: ${HOST_EXE}`); + } + + const snapshot = snapshotInstallState(); + fs.mkdirSync(INSTALL_ROOT, { recursive: true }); + let manifestPath = ""; + try { + fs.copyFileSync(HOST_EXE, INSTALLED_HOST_EXE); + + manifestPath = writeHostManifest(); + for (const registryPath of REGISTRY_PATHS) { + runReg(["add", registryPath, "/ve", "/t", "REG_SZ", "/d", manifestPath, "/f"], "Registry registration failed."); + } + } catch (error) { + try { + restoreInstallState(snapshot); + } catch (restoreError) { + throw new Error(`${error.message || error}; rollback failed: ${restoreError.message || restoreError}`); + } + throw error; + } + + console.log(`Registered ${HOST_NAME}`); + console.log(manifestPath); +} + +function writeHostManifest() { + const installDir = path.join(INSTALL_ROOT, "NativeMessagingHosts"); + fs.mkdirSync(installDir, { recursive: true }); + + const allowedOrigins = [ + `chrome-extension://${DEFAULT_PRODUCTION_EXTENSION_ID}/`, + ]; + + if (process.env.COPY_TEXT_DEV_EXTENSION_ID) { + validateExtensionId(process.env.COPY_TEXT_DEV_EXTENSION_ID); + allowedOrigins.push(`chrome-extension://${process.env.COPY_TEXT_DEV_EXTENSION_ID}/`); + } + + const manifest = { + name: HOST_NAME, + description: "Copy Text Without Selecting native clipboard companion", + path: INSTALLED_HOST_EXE, + type: "stdio", + allowed_origins: allowedOrigins, + }; + + const manifestPath = path.join(installDir, `${HOST_NAME}.json`); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + return manifestPath; +} + +function validateExtensionId(extensionId) { + if (!/^[a-p]{32}$/.test(String(extensionId || ""))) { + throw new Error("COPY_TEXT_DEV_EXTENSION_ID must be a 32-character Chrome extension ID."); + } +} + +function runReg(args, message) { + const result = spawnSync("reg", args, { encoding: "utf8" }); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || message).trim()); + } + return result.stdout; +} + +function snapshotInstallState() { + const manifestPath = path.join(INSTALL_ROOT, "NativeMessagingHosts", `${HOST_NAME}.json`); + return { + exe: readOptionalFile(INSTALLED_HOST_EXE), + manifest: readOptionalFile(manifestPath), + manifestPath, + registry: REGISTRY_PATHS.map(function (registryPath) { + return { path: registryPath, value: readRegistryDefault(registryPath) }; + }), + }; +} + +function restoreInstallState(snapshot) { + const restoreErrors = []; + try { + restoreOptionalFile(INSTALLED_HOST_EXE, snapshot.exe); + } catch (error) { + restoreErrors.push(`restore ${INSTALLED_HOST_EXE}: ${error.message || error}`); + } + try { + restoreOptionalFile(snapshot.manifestPath, snapshot.manifest); + } catch (error) { + restoreErrors.push(`restore ${snapshot.manifestPath}: ${error.message || error}`); + } + snapshot.registry.forEach(function (entry) { + if (entry.value === null) { + const result = spawnSync("reg", ["delete", entry.path, "/f"], { encoding: "utf8" }); + if (result.status !== 0 && !/unable to find|cannot find/i.test(result.stderr + result.stdout)) { + restoreErrors.push((result.stderr || result.stdout || `Failed to delete ${entry.path}`).trim()); + } + return; + } + const result = spawnSync("reg", ["add", entry.path, "/ve", "/t", "REG_SZ", "/d", entry.value, "/f"], { encoding: "utf8" }); + if (result.status !== 0) { + restoreErrors.push((result.stderr || result.stdout || `Failed to restore ${entry.path}`).trim()); + } + }); + if (restoreErrors.length) { + throw new Error(restoreErrors.join("; ")); + } +} + +function readOptionalFile(filePath) { + return fs.existsSync(filePath) ? fs.readFileSync(filePath) : null; +} + +function restoreOptionalFile(filePath, contents) { + if (contents === null) { + fs.rmSync(filePath, { force: true }); + return; + } + if (fs.existsSync(filePath) && Buffer.compare(fs.readFileSync(filePath), contents) === 0) { + return; + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); +} + +function readRegistryDefault(registryPath) { + const result = spawnSync("reg", ["query", registryPath, "/ve"], { encoding: "utf8" }); + if (result.status !== 0) { + return null; + } + const match = result.stdout.match(/\(Default\)\s+REG_SZ\s+(.+)/); + return match ? match[1].trim() : null; +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message || error); + process.exitCode = 1; + } +} + +module.exports = { + main, + writeHostManifest, + REGISTRY_PATHS, + INSTALL_ROOT, + INSTALLED_HOST_EXE, + HOST_NAME, + validateExtensionId, + readRegistryDefault, +}; diff --git a/scripts/lib/native-tools.cjs b/scripts/lib/native-tools.cjs new file mode 100644 index 0000000..88f30bb --- /dev/null +++ b/scripts/lib/native-tools.cjs @@ -0,0 +1,41 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +function resolveTool(name, candidates, args = ["version"]) { + const direct = spawnSync(name, args, { encoding: "utf8" }); + if (!direct.error && direct.status === 0) { + return name; + } + + for (const candidate of candidates) { + if (!candidate || !fs.existsSync(candidate)) { + continue; + } + const result = spawnSync(candidate, args, { encoding: "utf8" }); + if (!result.error && result.status === 0) { + return candidate; + } + } + + return name; +} + +function resolveGo() { + return resolveTool("go", [ + "C:\\Program Files\\Go\\bin\\go.exe", + path.join(os.homedir(), "sdk", "go", "bin", "go.exe"), + ]); +} + +function resolveWails() { + return resolveTool("wails", [ + path.join(os.homedir(), "go", "bin", "wails.exe"), + ]); +} + +module.exports = { + resolveGo, + resolveWails, +}; diff --git a/scripts/smoke-native-host.cjs b/scripts/smoke-native-host.cjs new file mode 100644 index 0000000..dad24a0 --- /dev/null +++ b/scripts/smoke-native-host.cjs @@ -0,0 +1,142 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const HOST_EXE = path.join(PROJECT_ROOT, "dist", "native", "copy-text-companion.exe"); + +async function main() { + if (!fs.existsSync(HOST_EXE)) { + throw new Error(`Native host executable is missing. Run npm run native:build first: ${HOST_EXE}`); + } + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "copy-text-native-host-")); + const host = spawn(HOST_EXE, ["--native-messaging"], { + env: Object.assign({}, process.env, { APPDATA: tempRoot }), + stdio: ["pipe", "pipe", "pipe"], + }); + + const reader = createNativeReader(host.stdout); + const stderr = []; + host.stderr.on("data", function (chunk) { + stderr.push(chunk); + }); + + try { + const ping = await request(host, reader, "PING", { extensionVersion: "test" }); + assertOk(ping, "PING"); + + const stored = await request(host, reader, "CLIPBOARD_EVENT", { + text: "{\"ok\":true}", + source: "integration-test", + hostname: "example.com", + }); + assertOk(stored, "CLIPBOARD_EVENT"); + if (!stored.payload || !stored.payload.stored) { + throw new Error("Expected CLIPBOARD_EVENT to store history."); + } + + const listed = await request(host, reader, "HISTORY_LIST", { limit: 5 }); + assertOk(listed, "HISTORY_LIST"); + if (!listed.payload || !Array.isArray(listed.payload.history) || listed.payload.history.length !== 1) { + throw new Error("Expected HISTORY_LIST to return one item."); + } + } finally { + host.kill(); + await waitForExit(host).catch(function () {}); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + if (stderr.length) { + const text = Buffer.concat(stderr).toString("utf8").trim(); + if (text) { + console.warn(text); + } + } + + console.log("Native host smoke test passed."); +} + +function waitForExit(child) { + return new Promise(function (resolve) { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once("exit", resolve); + setTimeout(resolve, 1500); + }); +} + +function request(host, reader, type, payload) { + const message = { + id: `${type}-${Date.now()}`, + version: 1, + type, + payload: payload || {}, + }; + host.stdin.write(encodeNativeMessage(message)); + return reader.next(); +} + +function encodeNativeMessage(message) { + const payload = Buffer.from(JSON.stringify(message), "utf8"); + const length = Buffer.alloc(4); + length.writeUInt32LE(payload.length, 0); + return Buffer.concat([length, payload]); +} + +function createNativeReader(stream) { + let buffer = Buffer.alloc(0); + const waiters = []; + + stream.on("data", function (chunk) { + buffer = Buffer.concat([buffer, chunk]); + flush(); + }); + + function flush() { + while (waiters.length && buffer.length >= 4) { + const length = buffer.readUInt32LE(0); + if (buffer.length < 4 + length) { + return; + } + const payload = buffer.subarray(4, 4 + length); + buffer = buffer.subarray(4 + length); + waiters.shift().resolve(JSON.parse(payload.toString("utf8"))); + } + } + + return { + next: function () { + return new Promise(function (resolve, reject) { + const timer = setTimeout(function () { + reject(new Error("Timed out waiting for native host response.")); + }, 3000); + waiters.push({ + resolve: function (value) { + clearTimeout(timer); + resolve(value); + }, + }); + flush(); + }); + }, + }; +} + +function assertOk(response, type) { + if (!response || !response.ok) { + throw new Error(`${type} failed: ${JSON.stringify(response)}`); + } +} + +if (require.main === module) { + main().catch(function (error) { + console.error(error.message || error); + process.exitCode = 1; + }); +} + +module.exports = { main }; diff --git a/scripts/smoke-tray-windows.cjs b/scripts/smoke-tray-windows.cjs new file mode 100644 index 0000000..9f80b35 --- /dev/null +++ b/scripts/smoke-tray-windows.cjs @@ -0,0 +1,574 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const HOST_EXE = path.join(PROJECT_ROOT, "dist", "native", "copy-text-companion.exe"); + +function main() { + if (process.platform !== "win32") { + console.log("Windows tray smoke skipped on non-Windows platforms."); + return; + } + + if (!fs.existsSync(HOST_EXE)) { + throw new Error(`Companion executable is missing. Run npm run native:build first: ${HOST_EXE}`); + } + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "copy-text-tray-smoke-")); + const appDataRoot = path.join(tempRoot, "appdata"); + const scriptPath = path.join(tempRoot, "smoke-tray.ps1"); + fs.mkdirSync(appDataRoot, { recursive: true }); + fs.writeFileSync(scriptPath, createPowerShellSmoke(), "utf8"); + + let primaryError = null; + try { + const powershellArgs = [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + "-ExePath", + HOST_EXE, + "-AppDataPath", + appDataRoot, + ]; + if (process.env.COPY_TEXT_TRAY_FORCE_MESSAGE_INJECTION === "1") { + powershellArgs.push("-ForceMessageInjection"); + } + const result = spawnSync("powershell", powershellArgs, { + cwd: PROJECT_ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + if (result.status !== 0) { + throw new Error(`Windows tray smoke failed with exit code ${result.status}.`); + } + } catch (error) { + primaryError = error; + } finally { + try { + waitForFilesystemRelease(750); + fs.rmSync(tempRoot, { + recursive: true, + force: true, + maxRetries: 40, + retryDelay: 250, + }); + } catch (cleanupError) { + if (!primaryError) { + throw cleanupError; + } + primaryError.message += ` Cleanup also failed for ${tempRoot}: ${cleanupError.message || cleanupError}`; + } + } + if (primaryError) { + throw primaryError; + } +} + +function waitForFilesystemRelease(delayMs) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs); +} + +function createPowerShellSmoke() { + return String.raw` +param( + [Parameter(Mandatory = $true)] + [string]$ExePath, + + [Parameter(Mandatory = $true)] + [string]$AppDataPath, + + [switch]$ForceMessageInjection +) + +$ErrorActionPreference = "Stop" + +function Fail([string]$Message) { + throw $Message +} + +Add-Type -TypeDefinition @" +using System; +using System.Text; +using System.Runtime.InteropServices; + +public static class CopyTextTraySmoke { + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential)] + public struct RECT { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + public struct NOTIFYICONIDENTIFIER { + public int cbSize; + public IntPtr hWnd; + public uint uID; + public Guid guidItem; + } + + [DllImport("user32.dll")] + public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] + public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); + + [DllImport("user32.dll", SetLastError = true)] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll")] + public static extern bool IsIconic(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool SetCursorPos(int X, int Y); + + [DllImport("user32.dll")] + public static extern bool BlockInput(bool fBlockIt); + + [DllImport("user32.dll")] + public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo); + + [DllImport("user32.dll")] + public static extern bool SetProcessDPIAware(); + + [DllImport("user32.dll", SetLastError = true)] + public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, UIntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool PostMessage(IntPtr hWnd, uint Msg, UIntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool EndMenu(); + + [DllImport("user32.dll", SetLastError = true)] + public static extern int GetMenuItemCount(IntPtr hMenu); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern int GetMenuString(IntPtr hMenu, uint uIDItem, StringBuilder lpString, int cchMax, uint flags); + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool GetMenuItemRect(IntPtr hWnd, IntPtr hMenu, uint uItem, out RECT lprcItem); + + [DllImport("shell32.dll", SetLastError = true)] + public static extern int Shell_NotifyIconGetRect(ref NOTIFYICONIDENTIFIER identifier, out RECT iconLocation); +} +"@ +Add-Type -AssemblyName UIAutomationClient + +[void][CopyTextTraySmoke]::SetProcessDPIAware() + +$MOUSEEVENTF_LEFTDOWN = [uint32]0x0002 +$MOUSEEVENTF_LEFTUP = [uint32]0x0004 +$MOUSEEVENTF_RIGHTDOWN = [uint32]0x0008 +$MOUSEEVENTF_RIGHTUP = [uint32]0x0010 +$MN_GETHMENU = [uint32]0x01E1 +$MF_BYPOSITION = [uint32]0x00000400 +$TRAY_CALLBACK_MESSAGE = [uint32](0x8000 + 0x531) +$WM_COMMAND = [uint32]0x0111 +$WM_LBUTTONUP = [uint32]0x0202 +$WM_LBUTTONDBLCLK = [uint32]0x0203 +$WM_RBUTTONUP = [uint32]0x0205 +$ID_TRAY_SHOW = [uint32]1001 +$ID_TRAY_MINIMIZE = [uint32]1002 +$ID_TRAY_QUIT = [uint32]1003 + +function Invoke-MouseClick([int]$X, [int]$Y, [string]$Button, [int]$Count = 1) { + if (-not [CopyTextTraySmoke]::SetCursorPos($X, $Y)) { + [void][CopyTextTraySmoke]::BlockInput($false) + Start-Sleep -Milliseconds 100 + if (-not [CopyTextTraySmoke]::SetCursorPos($X, $Y)) { + Fail "SetCursorPos failed. Run this smoke from an interactive Windows desktop." + } + } + Start-Sleep -Milliseconds 120 + for ($i = 0; $i -lt $Count; $i++) { + if ($Button -eq "right") { + [CopyTextTraySmoke]::mouse_event($MOUSEEVENTF_RIGHTDOWN, [uint32]$X, [uint32]$Y, 0, [UIntPtr]::Zero) + Start-Sleep -Milliseconds 50 + [CopyTextTraySmoke]::mouse_event($MOUSEEVENTF_RIGHTUP, [uint32]$X, [uint32]$Y, 0, [UIntPtr]::Zero) + } else { + [CopyTextTraySmoke]::mouse_event($MOUSEEVENTF_LEFTDOWN, [uint32]$X, [uint32]$Y, 0, [UIntPtr]::Zero) + Start-Sleep -Milliseconds 50 + [CopyTextTraySmoke]::mouse_event($MOUSEEVENTF_LEFTUP, [uint32]$X, [uint32]$Y, 0, [UIntPtr]::Zero) + } + Start-Sleep -Milliseconds 170 + } +} + +function Wait-Until([scriptblock]$Check, [string]$Label, [int]$TimeoutMs = 8000) { + $value = Wait-Optional $Check $TimeoutMs + if ($value) { + return $value + } + Fail "Timed out waiting for $Label." +} + +function Wait-Optional([scriptblock]$Check, [int]$TimeoutMs = 8000) { + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs) + do { + $value = & $Check + if ($value) { + return $value + } + Start-Sleep -Milliseconds 150 + } while ([DateTime]::UtcNow -lt $deadline) + return $null +} + +function Find-ProcessWindows([int]$ProcessId) { + $script:trayHwnd = [IntPtr]::Zero + $script:mainHwnd = [IntPtr]::Zero + $targetPid = [uint32]$ProcessId + [CopyTextTraySmoke+EnumWindowsProc]$enum = { + param([IntPtr]$hWnd, [IntPtr]$lParam) + $windowPid = [uint32]0 + [void][CopyTextTraySmoke]::GetWindowThreadProcessId($hWnd, [ref]$windowPid) + if ($windowPid -ne $targetPid) { + return $true + } + $className = New-Object System.Text.StringBuilder 256 + [void][CopyTextTraySmoke]::GetClassName($hWnd, $className, $className.Capacity) + $name = $className.ToString() + if ($name -eq "CopyTextCompanionTrayWindow-$targetPid") { + $script:trayHwnd = $hWnd + } elseif ([CopyTextTraySmoke]::IsWindowVisible($hWnd)) { + $script:mainHwnd = $hWnd + } + return $true + } + [void][CopyTextTraySmoke]::EnumWindows($enum, [IntPtr]::Zero) + return @{ + Tray = $script:trayHwnd + Main = $script:mainHwnd + } +} + +function Get-TrayIconCenter([IntPtr]$TrayHwnd) { + $identifier = New-Object CopyTextTraySmoke+NOTIFYICONIDENTIFIER + $identifier.cbSize = [Runtime.InteropServices.Marshal]::SizeOf([type][CopyTextTraySmoke+NOTIFYICONIDENTIFIER]) + $identifier.hWnd = $TrayHwnd + $identifier.uID = 1 + $identifier.guidItem = [Guid]::Empty + $rect = New-Object CopyTextTraySmoke+RECT + $hr = [CopyTextTraySmoke]::Shell_NotifyIconGetRect([ref]$identifier, [ref]$rect) + if ($hr -ne 0) { + Fail ("Shell_NotifyIconGetRect failed with HRESULT 0x{0:X8}. The tray icon may be hidden by notification overflow or the desktop may be non-interactive." -f ($hr -band 0xffffffff)) + } + if ($rect.Right -le $rect.Left -or $rect.Bottom -le $rect.Top) { + Fail "Shell returned an empty tray icon rectangle." + } + return @{ + X = [int](($rect.Left + $rect.Right) / 2) + Y = [int](($rect.Top + $rect.Bottom) / 2) + Rect = "$($rect.Left),$($rect.Top),$($rect.Right),$($rect.Bottom)" + } +} + +function Open-HiddenIconsOverflow() { + $root = [System.Windows.Automation.AutomationElement]::RootElement + $buttonCondition = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Button + ) + $buttons = $root.FindAll([System.Windows.Automation.TreeScope]::Subtree, $buttonCondition) + $fallback = $null + for ($i = 0; $i -lt $buttons.Count; $i++) { + $button = $buttons.Item($i) + $name = $button.Current.Name + $className = $button.Current.ClassName + if ($className -eq "SystemTray.NormalButton" -and -not $fallback) { + $fallback = $button + } + if ($name -notmatch "Hidden|Icons|Overflow") { + continue + } + Write-Host "Opening tray overflow via '$name' ($className)." + Click-AutomationElementCenter $button + return + } + if ($fallback) { + Write-Host "Opening tray overflow via fallback '$($fallback.Current.Name)' ($($fallback.Current.ClassName))." + Click-AutomationElementCenter $fallback + return + } + Fail "Could not find the Windows 'Show Hidden Icons' tray overflow button." +} + +function Click-AutomationElementCenter([System.Windows.Automation.AutomationElement]$Element) { + $rect = $Element.Current.BoundingRectangle + if ($rect.Width -le 0 -or $rect.Height -le 0) { + Fail "Automation element has an empty bounding rectangle." + } + $x = [int](($rect.Left + $rect.Right) / 2) + $y = [int](($rect.Top + $rect.Bottom) / 2) + Invoke-MouseClick $x $y "left" + Start-Sleep -Milliseconds 600 +} + +function Get-MenuLabels([IntPtr]$MenuHandle) { + $labels = @() + $count = [CopyTextTraySmoke]::GetMenuItemCount($MenuHandle) + if ($count -lt 0) { + return $labels + } + for ($i = 0; $i -lt $count; $i++) { + $text = New-Object System.Text.StringBuilder 256 + [void][CopyTextTraySmoke]::GetMenuString($MenuHandle, [uint32]$i, $text, $text.Capacity, $MF_BYPOSITION) + $labels += $text.ToString() + } + return $labels +} + +function Find-PopupMenu() { + $script:menuInfo = $null + [CopyTextTraySmoke+EnumWindowsProc]$enum = { + param([IntPtr]$hWnd, [IntPtr]$lParam) + if (-not [CopyTextTraySmoke]::IsWindowVisible($hWnd)) { + return $true + } + $className = New-Object System.Text.StringBuilder 256 + [void][CopyTextTraySmoke]::GetClassName($hWnd, $className, $className.Capacity) + if ($className.ToString() -ne "#32768") { + return $true + } + $menuHandle = [CopyTextTraySmoke]::SendMessage($hWnd, $MN_GETHMENU, [UIntPtr]::Zero, [IntPtr]::Zero) + if ($menuHandle -eq [IntPtr]::Zero) { + return $true + } + $labels = Get-MenuLabels $menuHandle + if ($labels -contains "Show" -and $labels -contains "Minimize" -and $labels -contains "Quit") { + $script:menuInfo = @{ + Hwnd = $hWnd + Handle = $menuHandle + Labels = $labels + } + return $false + } + return $true + } + [void][CopyTextTraySmoke]::EnumWindows($enum, [IntPtr]::Zero) + return $script:menuInfo +} + +function Open-TrayMenu([IntPtr]$TrayHwnd) { + $trayCenter = Get-TrayIconCenter $TrayHwnd + Invoke-MouseClick $trayCenter.X $trayCenter.Y "right" + $menu = Wait-Optional { Find-PopupMenu } 1800 + if ($menu) { + return $menu + } + + # Windows can hide new tray icons behind notification overflow. Open that + # shell panel explicitly, then Shell_NotifyIconGetRect resolves to the real + # icon rectangle inside the overflow panel. + Open-HiddenIconsOverflow + $trayCenter = Get-TrayIconCenter $TrayHwnd + Write-Host "Retry tray icon rectangle after overflow: $($trayCenter.Rect)" + Invoke-MouseClick $trayCenter.X $trayCenter.Y "right" + return Wait-Until { Find-PopupMenu } "tray context menu with Show, Minimize, and Quit" +} + +function Invoke-TrayRestoreClick([IntPtr]$TrayHwnd, [IntPtr]$MainHwnd, [int]$Count, [string]$Label) { + $trayCenter = Get-TrayIconCenter $TrayHwnd + Invoke-MouseClick $trayCenter.X $trayCenter.Y "left" $Count + $restored = Wait-Optional { -not [CopyTextTraySmoke]::IsIconic($MainHwnd) } 1400 + if ($restored) { + return + } + + Open-HiddenIconsOverflow + $trayCenter = Get-TrayIconCenter $TrayHwnd + Write-Host "Retry tray restore rectangle after overflow: $($trayCenter.Rect)" + Invoke-MouseClick $trayCenter.X $trayCenter.Y "left" $Count + [void](Wait-Until { -not [CopyTextTraySmoke]::IsIconic($MainHwnd) } $Label) +} + +function Click-MenuItem($MenuInfo, [string]$Label) { + $labels = @($MenuInfo.Labels) + $index = [Array]::IndexOf($labels, $Label) + if ($index -lt 0) { + Fail "Tray menu item '$Label' was not found. Labels: $($labels -join ', ')" + } + $rect = New-Object CopyTextTraySmoke+RECT + if (-not [CopyTextTraySmoke]::GetMenuItemRect($MenuInfo.Hwnd, $MenuInfo.Handle, [uint32]$index, [ref]$rect)) { + Fail "GetMenuItemRect failed for tray menu item '$Label'." + } + $x = [int](($rect.Left + $rect.Right) / 2) + $y = [int](($rect.Top + $rect.Bottom) / 2) + Invoke-MouseClick $x $y "left" +} + +function Post-TrayCallback([IntPtr]$TrayHwnd, [uint32]$EventMessage) { + if (-not [CopyTextTraySmoke]::PostMessage( + $TrayHwnd, + $TRAY_CALLBACK_MESSAGE, + [UIntPtr]::Zero, + [IntPtr][int64]$EventMessage + )) { + Fail "PostMessage failed for tray callback $EventMessage." + } +} + +function Post-TrayCommand([IntPtr]$TrayHwnd, [uint32]$Command) { + if (-not [CopyTextTraySmoke]::PostMessage( + $TrayHwnd, + $WM_COMMAND, + [UIntPtr][uint64]$Command, + [IntPtr]::Zero + )) { + Fail "PostMessage failed for tray command $Command." + } +} + +function Test-TrayWithWindowMessages([IntPtr]$TrayHwnd, [IntPtr]$MainHwnd, $Process) { + Post-TrayCallback $TrayHwnd $WM_RBUTTONUP + $menu = Wait-Until { Find-PopupMenu } "tray context menu with Show, Minimize, and Quit" + Write-Output "Tray menu labels: $(@($menu.Labels) -join ', ')" + [void][CopyTextTraySmoke]::EndMenu() + Start-Sleep -Milliseconds 150 + + Post-TrayCommand $TrayHwnd $ID_TRAY_MINIMIZE + [void](Wait-Until { [CopyTextTraySmoke]::IsIconic($MainHwnd) } "tray Minimize command to minimize the window") + + Post-TrayCallback $TrayHwnd $WM_LBUTTONUP + [void](Wait-Until { -not [CopyTextTraySmoke]::IsIconic($MainHwnd) } "tray left-click callback to restore the window") + Start-Sleep -Milliseconds 500 + + Post-TrayCommand $TrayHwnd $ID_TRAY_MINIMIZE + [void](Wait-Until { [CopyTextTraySmoke]::IsIconic($MainHwnd) } "tray Minimize before Show command") + Post-TrayCommand $TrayHwnd $ID_TRAY_SHOW + [void](Wait-Until { -not [CopyTextTraySmoke]::IsIconic($MainHwnd) } "tray Show command to restore the window") + Start-Sleep -Milliseconds 500 + + Post-TrayCommand $TrayHwnd $ID_TRAY_MINIMIZE + [void](Wait-Until { [CopyTextTraySmoke]::IsIconic($MainHwnd) } "tray Minimize before double-click callback") + Post-TrayCallback $TrayHwnd $WM_LBUTTONDBLCLK + [void](Wait-Until { -not [CopyTextTraySmoke]::IsIconic($MainHwnd) } "tray double-click callback to restore the window") + Start-Sleep -Milliseconds 500 + + Post-TrayCommand $TrayHwnd $ID_TRAY_QUIT + if (-not $Process.WaitForExit(6000)) { + Fail "Tray Quit command did not exit the companion process." + } + + Write-Output "Windows tray smoke passed with message injection: icon registered, menu labels visible, click callbacks, Show, Minimize, and Quit." +} + +$existing = Get-Process copy-text-companion -ErrorAction SilentlyContinue +if ($existing) { + Fail "copy-text-companion.exe is already running. Close it before tray smoke so the test owns the process." +} + +$proc = $null +try { + $previousAppData = [Environment]::GetEnvironmentVariable("APPDATA", "Process") + [Environment]::SetEnvironmentVariable("APPDATA", $AppDataPath, "Process") + try { + $proc = Start-Process -FilePath $ExePath -PassThru + } finally { + [Environment]::SetEnvironmentVariable("APPDATA", $previousAppData, "Process") + } + + Start-Sleep -Seconds 4 + $proc.Refresh() + if ($proc.HasExited) { + Fail "Companion exited early with code $($proc.ExitCode)." + } + + $windows = Wait-Until { + $found = Find-ProcessWindows $proc.Id + if ($found.Tray -ne [IntPtr]::Zero -and $found.Main -ne [IntPtr]::Zero) { + return $found + } + return $null + } "companion tray and main windows" + + $trayCenter = Get-TrayIconCenter $windows.Tray + Write-Output "Initial tray icon rectangle: $($trayCenter.Rect)" + + if ($ForceMessageInjection -or -not [CopyTextTraySmoke]::SetCursorPos($trayCenter.X, $trayCenter.Y)) { + Write-Output "Physical cursor injection is unavailable; using Win32 tray message injection." + Test-TrayWithWindowMessages $windows.Tray $windows.Main $proc + } else { + try { + $menu = Open-TrayMenu $windows.Tray + Write-Output "Tray menu labels: $(@($menu.Labels) -join ', ')" + Click-MenuItem $menu "Minimize" + [void](Wait-Until { [CopyTextTraySmoke]::IsIconic($windows.Main) } "tray Minimize to minimize the window") + + Invoke-TrayRestoreClick $windows.Tray $windows.Main 1 "tray left-click to restore the window" + + $menu = Open-TrayMenu $windows.Tray + Click-MenuItem $menu "Minimize" + [void](Wait-Until { [CopyTextTraySmoke]::IsIconic($windows.Main) } "tray Minimize before Show") + + $menu = Open-TrayMenu $windows.Tray + Click-MenuItem $menu "Show" + [void](Wait-Until { -not [CopyTextTraySmoke]::IsIconic($windows.Main) } "tray Show to restore the window") + + $menu = Open-TrayMenu $windows.Tray + Click-MenuItem $menu "Minimize" + [void](Wait-Until { [CopyTextTraySmoke]::IsIconic($windows.Main) } "tray Minimize before double-click restore") + + Invoke-TrayRestoreClick $windows.Tray $windows.Main 2 "tray double-click to restore the window" + + $menu = Open-TrayMenu $windows.Tray + Click-MenuItem $menu "Quit" + $exited = $proc.WaitForExit(6000) + if (-not $exited) { + Fail "Tray Quit did not exit the companion process." + } + + Write-Output "Windows tray smoke passed: icon registered, menu labels visible, left-click restore, double-click restore, Show, Minimize, and Quit." + } catch { + $proc.Refresh() + if ($proc.HasExited) { + throw + } + Write-Output "Physical tray interaction failed: $($_.Exception.Message)" + Write-Output "Falling back to Win32 tray message injection." + [void][CopyTextTraySmoke]::EndMenu() + Test-TrayWithWindowMessages $windows.Tray $windows.Main $proc + } + } +} finally { + if ($proc) { + $proc.Refresh() + if (-not $proc.HasExited) { + Stop-Process -Id $proc.Id -Force + [void]$proc.WaitForExit(5000) + } + $proc.Dispose() + } +} +`; +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.stack || error.message || error); + process.exitCode = 1; + } +} + +module.exports = { main }; diff --git a/scripts/uninstall-native-host-windows.cjs b/scripts/uninstall-native-host-windows.cjs new file mode 100644 index 0000000..f3b8fba --- /dev/null +++ b/scripts/uninstall-native-host-windows.cjs @@ -0,0 +1,47 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { + HOST_NAME, + REGISTRY_PATHS, + INSTALL_ROOT, + INSTALLED_HOST_EXE, +} = require("./install-native-host-windows.cjs"); + +function main() { + if (process.platform !== "win32") { + throw new Error("Native host registry removal is Windows-only."); + } + + for (const registryPath of REGISTRY_PATHS) { + deleteRegistryPath(registryPath); + } + + const installDir = path.join(INSTALL_ROOT, "NativeMessagingHosts"); + const manifestPath = path.join(installDir, `${HOST_NAME}.json`); + fs.rmSync(manifestPath, { force: true }); + fs.rmSync(INSTALLED_HOST_EXE, { force: true }); + fs.rmSync(installDir, { recursive: true, force: true }); + + console.log(`Unregistered ${HOST_NAME}`); +} + +function deleteRegistryPath(registryPath) { + const result = spawnSync("reg", ["delete", registryPath, "/f"], { + encoding: "utf8", + }); + if (result.status !== 0 && !/unable to find|cannot find/i.test(result.stderr + result.stdout)) { + throw new Error((result.stderr || result.stdout || `Failed to delete ${registryPath}`).trim()); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message || error); + process.exitCode = 1; + } +} + +module.exports = { main }; diff --git a/src/background/index.js b/src/background/index.js index c42f29e..6e234ee 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -1,7 +1,6 @@ const utils = require("../shared/core.js"); const { HISTORY_CLEANUP_ALARM, - trimHistory, initializeHistoryCleanup, saveAnalyticsEvent, } = require("./history-maintenance.js"); @@ -9,6 +8,8 @@ const { initializeExtension, reportBackgroundError, } = require("./registration.js"); +const { createNativeMessagingBridge } = require("./native-messaging.js"); +const { createLocalHistoryController } = require("./local-history.js"); const { triggerShortcutCopy } = require("./shortcut.js"); const CONTENT_SCRIPT_ID = "copy-text-with-alt-click-content"; @@ -16,9 +17,15 @@ const CONTENT_SCRIPT_ID = "copy-text-with-alt-click-content"; if (!utils) { console.error("CopyTextUtils is not available."); } else { + const nativeBridge = createNativeMessagingBridge(utils); + const localHistory = createLocalHistoryController(utils, nativeBridge); + localHistory.flushOutbox().catch(function (error) { + reportBackgroundError(utils, "Flushing pending companion history failed.", error); + }); + utils.addListenerSafely(chrome.runtime.onInstalled, function () { initializeExtension(utils, CONTENT_SCRIPT_ID, function (limit) { - return trimHistory(utils, limit); + return localHistory.trimHistory(limit); }).catch(function (error) { reportBackgroundError(utils, "Initializing extension on install failed.", error); }); @@ -26,7 +33,7 @@ if (!utils) { utils.addListenerSafely(chrome.runtime.onStartup, function () { initializeExtension(utils, CONTENT_SCRIPT_ID, function (limit) { - return trimHistory(utils, limit); + return localHistory.trimHistory(limit); }).catch(function (error) { reportBackgroundError(utils, "Initializing extension on startup failed.", error); }); @@ -38,7 +45,7 @@ if (!utils) { } initializeExtension(utils, CONTENT_SCRIPT_ID, function (limit) { - return trimHistory(utils, limit); + return localHistory.trimHistory(limit); }).catch(function (error) { reportBackgroundError(utils, "Re-initializing extension after settings change failed.", error); }); @@ -56,6 +63,11 @@ if (!utils) { }); }); + utils.addListenerSafely(chrome.runtime.onMessage, function (message, sender, sendResponse) { + return nativeBridge.handleRuntimeMessage(message, sender, sendResponse) + || localHistory.handleRuntimeMessage(message, sender, sendResponse); + }); + try { chrome.alarms.create(HISTORY_CLEANUP_ALARM, { periodInMinutes: 360 }); utils.addListenerSafely(chrome.alarms.onAlarm, function (alarm) { @@ -64,7 +76,7 @@ if (!utils) { } initializeHistoryCleanup(utils, function (limit) { - return trimHistory(utils, limit); + return localHistory.trimHistory(limit); }).catch(function (error) { reportBackgroundError(utils, "Running history cleanup failed.", error); }); diff --git a/src/background/local-history.js b/src/background/local-history.js new file mode 100644 index 0000000..9d6498f --- /dev/null +++ b/src/background/local-history.js @@ -0,0 +1,776 @@ +const OUTBOX_KEY = "copyHistorySyncOutbox"; +const OUTBOX_DB_NAME = "copyTextHistorySyncOutbox"; +const OUTBOX_DB_VERSION = 1; +const OUTBOX_OPERATION_STORE = "operations"; +const OUTBOX_META_STORE = "metadata"; +const OUTBOX_LEGACY_MIGRATED_IDS_KEY = "legacyMigratedIds"; +const SUPPORTED_OPERATION_TYPES = new Set(["upsert", "pin", "delete", "clear"]); + +function createLocalHistoryController(utils, nativeBridge, options) { + let storageQueue = Promise.resolve(); + let flushQueue = Promise.resolve(); + let nextOperationId = 1; + let nextQueueOrder = Date.now() * 1000; + const outboxStore = options && options.outboxStore + ? options.outboxStore + : createIndexedDBOutboxStore(utils); + + function enqueueStorage(work) { + const next = storageQueue.then(work, work); + storageQueue = next.catch(function () {}); + return next; + } + + function enqueueFlush() { + const next = flushQueue.then(flushOutboxInternal, flushOutboxInternal); + flushQueue = next.catch(function () {}); + return next; + } + + function handleRuntimeMessage(message, sender, sendResponse) { + if (!message || typeof message.type !== "string" || !message.type.startsWith("COPY_TEXT_LOCAL_HISTORY_")) { + return false; + } + + const payload = message.payload || {}; + let work = null; + if (message.type === "COPY_TEXT_LOCAL_HISTORY_ADD") { + work = handleAdd(payload); + } else if (message.type === "COPY_TEXT_LOCAL_HISTORY_LIST") { + work = handleList(payload); + } else if (message.type === "COPY_TEXT_LOCAL_HISTORY_DELETE") { + work = handleDelete(payload); + } else if (message.type === "COPY_TEXT_LOCAL_HISTORY_CLEAR") { + work = handleClear(payload); + } else if (message.type === "COPY_TEXT_LOCAL_HISTORY_PIN") { + work = handlePin(payload); + } else if (message.type === "COPY_TEXT_LOCAL_HISTORY_SYNC") { + work = enqueueFlush(); + } else if (message.type === "COPY_TEXT_LOCAL_HISTORY_OUTBOX_STATUS") { + work = handleOutboxStatus(); + } + + if (work) { + work.then(sendResponse, function (error) { + sendResponse({ + ok: false, + error: { + code: "HISTORY_OPERATION_FAILED", + message: error && error.message ? error.message : String(error), + recoverable: true, + }, + }); + }); + return true; + } + + sendResponse({ + ok: false, + error: { + code: "UNKNOWN_MESSAGE", + message: "Unknown local history message.", + recoverable: false, + }, + }); + return false; + } + + async function handleAdd(payload) { + const limit = utils.normalizeCopyHistoryLimit(payload.limit); + const entry = payload.entry || {}; + let nativeEvent = Object.assign({}, payload.nativeEvent || {}); + if (!nativeEvent.text && entry.text) { + nativeEvent.text = entry.text; + } + if (!nativeEvent.createdAt && entry.createdAt) { + nativeEvent.createdAt = entry.createdAt; + } + if (nativeBridge && typeof nativeBridge.normalizeClipboardEventPayload === "function") { + nativeEvent = nativeBridge.normalizeClipboardEventPayload(nativeEvent); + } + + const queued = await enqueueStorage(async function () { + const state = await readState(); + const history = limit + ? utils.pushHistoryEntry(state.copyHistory, entry, limit) + : state.copyHistory; + const operation = createOperation("upsert", { event: nativeEvent }); + const localOK = limit + ? await writeLocalHistory(history) + : true; + const outboxOK = await appendOutboxOperation(operation); + return { + ok: !!(localOK || outboxOK), + outboxOK, + operation, + local: limit + ? { ok: !!localOK, stored: !!localOK, count: history.length } + : { ok: true, stored: false, reason: "local-history-disabled" }, + }; + }); + + const nativeResult = queued.outboxOK + ? await enqueueFlush() + : await sendOperation(queued.operation); + const ok = !!(queued.outboxOK || (nativeResult && nativeResult.ok)); + + return { + ok, + payload: { + local: queued.local, + outbox: { ok: !!queued.outboxOK }, + native: nativeResult, + }, + error: ok ? undefined : { + code: "HISTORY_ADD_FAILED", + message: "Failed to store local history and forward to the companion.", + recoverable: true, + }, + }; + } + + async function handleList(payload) { + const syncResult = await enqueueFlush(); + if (syncResult.ok && syncResult.pending === 0 && nativeBridge && typeof nativeBridge.listHistory === "function") { + const nativeResponse = await nativeBridge.listHistory(payload || {}); + if (nativeResponse && nativeResponse.ok && nativeResponse.payload && Array.isArray(nativeResponse.payload.history)) { + return { + ok: true, + payload: { + history: nativeResponse.payload.history, + mode: "companion", + pending: 0, + }, + }; + } + } + + const state = await enqueueStorage(readState); + let history = state.copyHistory; + const query = String((payload && payload.query) || "").trim().toLowerCase(); + if (query) { + history = history.filter(function (entry) { + return [entry.text, entry.snippet, entry.source, entry.hostname, entry.url] + .join(" ") + .toLowerCase() + .includes(query); + }); + } + const limit = Number((payload && payload.limit) || 0); + if (limit > 0 && history.length > limit) { + history = history.slice(0, limit); + } + return { + ok: true, + payload: { + history, + mode: "local", + pending: state.outbox.length, + }, + }; + } + + async function handleDelete(payload) { + const requestedIds = Array.isArray(payload.ids) + ? payload.ids.map(String) + : [String(payload.id || "")]; + const ids = requestedIds.filter(Boolean); + if (!ids.length) { + return badPayload("history id is required."); + } + + const queued = await enqueueStorage(async function () { + const state = await readState(); + const textById = new Map(state.copyHistory.map(function (entry) { + return [entry.id, entry.text]; + })); + const explicitItems = Array.isArray(payload.items) ? payload.items : []; + explicitItems.forEach(function (item) { + if (item && item.id && item.text) { + textById.set(String(item.id), String(item.text)); + } + }); + if (payload.id && payload.text) { + textById.set(String(payload.id), String(payload.text)); + } + + const history = utils.deleteHistoryEntries(state.copyHistory, ids); + const localOK = await writeLocalHistory(history); + const operations = []; + let outboxOK = true; + for (const id of ids) { + const operation = createOperation("delete", { + id, + text: textById.get(id) || "", + }); + operations.push(operation); + outboxOK = (await appendOutboxOperation(operation)) && outboxOK; + } + return { ok: !!(localOK || outboxOK), localOK, outboxOK, operations }; + }); + + const nativeResult = queued.outboxOK ? await enqueueFlush() : await sendOperations(queued.operations); + return mutationResponse(queued.localOK, queued.outboxOK, nativeResult); + } + + async function handleClear() { + const queued = await enqueueStorage(async function () { + const operation = createOperation("clear", {}); + const localOK = await writeLocalHistory([]); + const outboxOK = await appendOutboxOperation(operation); + return { ok: !!(localOK || outboxOK), localOK, outboxOK, operation }; + }); + const nativeResult = queued.outboxOK ? await enqueueFlush() : await sendOperation(queued.operation); + return mutationResponse(queued.localOK, queued.outboxOK, nativeResult); + } + + async function handlePin(payload) { + const id = String(payload.id || ""); + if (!id) { + return badPayload("history id is required."); + } + + const queued = await enqueueStorage(async function () { + const state = await readState(); + const limit = await readCopyHistoryLimit(payload.limit, state.copyHistory); + const existing = state.copyHistory.find(function (entry) { + return entry.id === id; + }); + const text = String(payload.text || (existing && existing.text) || ""); + const history = utils.updateHistoryEntry(state.copyHistory, id, function (entry) { + entry.pinned = !!payload.pinned; + return entry; + }, limit); + const operation = createOperation("pin", { + id, + text, + pinned: !!payload.pinned, + }); + const localOK = await writeLocalHistory(history); + const outboxOK = await appendOutboxOperation(operation); + return { ok: !!(localOK || outboxOK), localOK, outboxOK, operation }; + }); + + const nativeResult = queued.outboxOK ? await enqueueFlush() : await sendOperation(queued.operation); + return mutationResponse(queued.localOK, queued.outboxOK, nativeResult); + } + + async function readCopyHistoryLimit(value, fallbackHistory) { + if (value !== undefined && value !== null && value !== "") { + return utils.normalizeCopyHistoryLimit(value); + } + try { + const settings = await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS || {}); + const merged = typeof utils.mergeSettings === "function" + ? utils.mergeSettings(settings) + : settings; + return utils.normalizeCopyHistoryLimit(merged && merged.copyHistoryLimit); + } catch (error) { + const currentLength = Array.isArray(fallbackHistory) ? fallbackHistory.length : 0; + return utils.normalizeCopyHistoryLimit(Math.max(1, currentLength)); + } + } + + function trimLocalHistory(limit) { + return enqueueStorage(async function () { + const state = await readState(); + const normalizedLimit = utils.normalizeCopyHistoryLimit(limit); + let history = utils.pushHistoryEntry(state.copyHistory, null, normalizedLimit); + const maxBytes = 4 * 1024 * 1024; + let serialized = JSON.stringify(history); + while (serialized.length > maxBytes && history.length > 1) { + let indexToRemove = -1; + for (let index = history.length - 1; index >= 0; index -= 1) { + if (!history[index].pinned) { + indexToRemove = index; + break; + } + } + if (indexToRemove === -1) { + indexToRemove = history.length - 1; + } + history.splice(indexToRemove, 1); + serialized = JSON.stringify(history); + } + return writeLocalHistory(history); + }); + } + + async function handleOutboxStatus() { + const outbox = await enqueueStorage(function () { + return outboxStore.list(); + }); + return { + ok: true, + payload: { + pending: outbox.length, + operations: outbox.map(function (operation) { + return { + id: operation.id, + type: operation.type, + createdAt: operation.createdAt, + queueOrder: operation.queueOrder, + }; + }), + }, + }; + } + + async function flushOutboxInternal() { + let synced = 0; + let lastResponse = null; + + while (true) { + const state = await enqueueStorage(readState); + if (!state.outbox.length) { + return { + ok: true, + synced, + pending: 0, + response: lastResponse, + }; + } + + const operation = state.outbox[0]; + const response = await sendOperation(operation); + lastResponse = response; + if (!response || !response.ok) { + return Object.assign({ + ok: false, + synced, + pending: state.outbox.length, + response, + }, response && response.error ? { error: response.error } : {}); + } + + const removed = await enqueueStorage(function () { + return outboxStore.remove(operation.id); + }); + if (!removed) { + return { + ok: false, + synced, + pending: state.outbox.length, + response, + error: { + code: "OUTBOX_WRITE_FAILED", + message: "Could not remove a synced history operation from local storage.", + recoverable: true, + }, + }; + } + synced += 1; + } + } + + async function sendOperations(operations) { + let last = { ok: true }; + for (const operation of operations || []) { + last = await sendOperation(operation); + if (!last || !last.ok) { + return last; + } + } + return last; + } + + async function sendOperation(operation) { + if (!operation || !nativeBridge) { + return nativeUnavailable(); + } + const payload = operation.payload || {}; + try { + if (operation.type === "upsert" && typeof nativeBridge.forwardClipboardEvent === "function") { + const event = Object.assign({}, payload.event || {}, { + operationAt: operation.createdAt, + }); + if (!event.createdAt) { + event.createdAt = operation.createdAt; + } + return await nativeBridge.forwardClipboardEvent(event); + } + if (operation.type === "delete" && typeof nativeBridge.deleteHistory === "function") { + return await nativeBridge.deleteHistory(Object.assign({}, payload, { operationAt: operation.createdAt })); + } + if (operation.type === "clear" && typeof nativeBridge.clearHistory === "function") { + return await nativeBridge.clearHistory({ operationAt: operation.createdAt }); + } + if (operation.type === "pin" && typeof nativeBridge.pinHistory === "function") { + return await nativeBridge.pinHistory(Object.assign({}, payload, { operationAt: operation.createdAt })); + } + return nativeUnavailable(); + } catch (error) { + return { + ok: false, + error: { + code: "NATIVE_FORWARD_FAILED", + message: error && error.message ? error.message : String(error), + recoverable: true, + }, + }; + } + } + + async function readState() { + const current = await utils.safeStorageGet("local", { copyHistory: [] }); + return { + copyHistory: Array.isArray(current.copyHistory) + ? current.copyHistory.map(utils.normalizeHistoryEntry).filter(Boolean) + : [], + outbox: await outboxStore.list(), + }; + } + + function writeLocalHistory(copyHistory) { + return utils.safeStorageSet("local", { + copyHistory: Array.isArray(copyHistory) ? copyHistory : [], + }); + } + + async function appendOutboxOperation(operation) { + try { + if (operation.type === "clear") { + return await outboxStore.replace([operation]); + } + return await outboxStore.append(operation); + } catch (error) { + return false; + } + } + + function createOperation(type, payload) { + const createdAt = Date.now(); + nextQueueOrder = Math.max(nextQueueOrder + 1, createdAt * 1000 + nextOperationId); + return { + id: "history-op-" + createdAt + "-" + nextOperationId++ + "-" + Math.random().toString(16).slice(2), + type, + createdAt, + queueOrder: nextQueueOrder, + payload: payload || {}, + }; + } + + function mutationResponse(localOK, outboxOK, nativeResult) { + const nativeOK = !!(nativeResult && nativeResult.ok); + const ok = !!(outboxOK || nativeOK); + return { + ok, + payload: { + local: { ok: !!localOK }, + outbox: { ok: !!outboxOK }, + native: nativeResult, + }, + error: ok ? undefined : { + code: "HISTORY_MUTATION_FAILED", + message: "Failed to update local history and the companion.", + recoverable: true, + }, + }; + } + + function badPayload(message) { + return { + ok: false, + error: { + code: "BAD_PAYLOAD", + message, + recoverable: false, + }, + }; + } + + function nativeUnavailable() { + return { + ok: false, + error: { + code: "HOST_UNAVAILABLE", + message: "Native history bridge is unavailable.", + recoverable: true, + }, + }; + } + + if (nativeBridge && typeof nativeBridge.setConnectedHandler === "function") { + nativeBridge.setConnectedHandler(enqueueFlush); + } + + return { + handleRuntimeMessage, + flushOutbox: enqueueFlush, + trimHistory: trimLocalHistory, + }; +} + +function createIndexedDBOutboxStore(utils) { + let initPromise = null; + let dbPromise = null; + let legacyMode = false; + + async function ensureInitialized() { + if (!initPromise) { + initPromise = initialize().catch(function (error) { + initPromise = null; + throw error; + }); + } + return initPromise; + } + + async function initialize() { + const db = await openDatabase(); + if (!db) { + legacyMode = true; + return; + } + + const legacy = await readLegacyOutbox(); + if (!legacy.length) { + return; + } + + const migratedIds = new Set(await readMeta(db, OUTBOX_LEGACY_MIGRATED_IDS_KEY, [])); + const existing = await listIDBOperations(db); + const pendingLegacy = legacy.filter(function (operation) { + return !migratedIds.has(operation.id); + }); + const merged = mergeOutbox(existing.concat(pendingLegacy)); + legacy.forEach(function (operation) { + migratedIds.add(operation.id); + }); + await writeIDBOperationsAndMeta(db, merged, OUTBOX_LEGACY_MIGRATED_IDS_KEY, Array.from(migratedIds)); + await writeLegacyOutbox([]); + } + + async function list() { + await ensureInitialized(); + if (legacyMode) { + return readLegacyOutbox(); + } + return listIDBOperations(await openDatabase()); + } + + async function append(operation) { + await ensureInitialized(); + const normalized = normalizeOutbox([operation])[0]; + if (!normalized) { + return false; + } + if (legacyMode) { + const current = await readLegacyOutbox(); + return writeLegacyOutbox(current.concat(normalized)); + } + await putIDBOperation(await openDatabase(), normalized); + return true; + } + + async function replace(operations) { + await ensureInitialized(); + const normalized = normalizeOutbox(operations); + if (legacyMode) { + return writeLegacyOutbox(normalized); + } + await replaceIDBOperations(await openDatabase(), normalized); + return true; + } + + async function remove(id) { + await ensureInitialized(); + const operationId = String(id || ""); + if (!operationId) { + return false; + } + if (legacyMode) { + const current = await readLegacyOutbox(); + return writeLegacyOutbox(current.filter(function (operation) { + return operation.id !== operationId; + })); + } + await deleteIDBOperation(await openDatabase(), operationId); + return true; + } + + async function openDatabase() { + if (legacyMode) { + return null; + } + if (!dbPromise) { + dbPromise = openOutboxDatabase(utils).catch(function () { + legacyMode = true; + return null; + }); + } + return dbPromise; + } + + async function readLegacyOutbox() { + const current = await utils.safeStorageGet("local", { [OUTBOX_KEY]: [] }); + return normalizeOutbox(current[OUTBOX_KEY]); + } + + async function writeLegacyOutbox(operations) { + return utils.safeStorageSet("local", { [OUTBOX_KEY]: normalizeOutbox(operations) }); + } + + return { + list, + append, + replace, + remove, + }; +} + +function openOutboxDatabase(utils) { + const factory = (utils && utils.indexedDB) || (typeof globalThis !== "undefined" && globalThis.indexedDB); + if (!factory || typeof factory.open !== "function") { + return Promise.resolve(null); + } + + return new Promise(function (resolve, reject) { + const request = factory.open(OUTBOX_DB_NAME, OUTBOX_DB_VERSION); + request.onupgradeneeded = function (event) { + const db = event.target.result; + if (!db.objectStoreNames.contains(OUTBOX_OPERATION_STORE)) { + db.createObjectStore(OUTBOX_OPERATION_STORE, { keyPath: "id" }); + } + if (!db.objectStoreNames.contains(OUTBOX_META_STORE)) { + db.createObjectStore(OUTBOX_META_STORE, { keyPath: "key" }); + } + }; + request.onsuccess = function (event) { + resolve(event.target.result); + }; + request.onerror = function () { + reject(request.error || new Error("Could not open history sync outbox database.")); + }; + request.onblocked = function () { + reject(new Error("History sync outbox database upgrade was blocked.")); + }; + }); +} + +async function listIDBOperations(db) { + if (!db) { + return []; + } + const tx = db.transaction([OUTBOX_OPERATION_STORE], "readonly"); + const operations = await idbRequest(tx.objectStore(OUTBOX_OPERATION_STORE).getAll()); + await idbComplete(tx); + return normalizeOutbox(operations); +} + +async function putIDBOperation(db, operation) { + const tx = db.transaction([OUTBOX_OPERATION_STORE], "readwrite"); + await idbRequest(tx.objectStore(OUTBOX_OPERATION_STORE).put(operation)); + await idbComplete(tx); +} + +async function replaceIDBOperations(db, operations) { + const tx = db.transaction([OUTBOX_OPERATION_STORE], "readwrite"); + const store = tx.objectStore(OUTBOX_OPERATION_STORE); + await idbRequest(store.clear()); + for (const operation of operations) { + await idbRequest(store.put(operation)); + } + await idbComplete(tx); +} + +async function deleteIDBOperation(db, id) { + const tx = db.transaction([OUTBOX_OPERATION_STORE], "readwrite"); + await idbRequest(tx.objectStore(OUTBOX_OPERATION_STORE).delete(id)); + await idbComplete(tx); +} + +async function readMeta(db, key, fallbackValue) { + const tx = db.transaction([OUTBOX_META_STORE], "readonly"); + const record = await idbRequest(tx.objectStore(OUTBOX_META_STORE).get(key)); + await idbComplete(tx); + return record && Object.prototype.hasOwnProperty.call(record, "value") ? record.value : fallbackValue; +} + +async function writeIDBOperationsAndMeta(db, operations, key, value) { + const tx = db.transaction([OUTBOX_OPERATION_STORE, OUTBOX_META_STORE], "readwrite"); + const operationsStore = tx.objectStore(OUTBOX_OPERATION_STORE); + await idbRequest(operationsStore.clear()); + for (const operation of operations) { + await idbRequest(operationsStore.put(operation)); + } + await idbRequest(tx.objectStore(OUTBOX_META_STORE).put({ key, value })); + await idbComplete(tx); +} + +function idbRequest(request) { + return new Promise(function (resolve, reject) { + request.onsuccess = function (event) { + resolve(event.target.result); + }; + request.onerror = function () { + reject(request.error || new Error("IndexedDB request failed.")); + }; + }); +} + +function idbComplete(transaction) { + return new Promise(function (resolve, reject) { + transaction.oncomplete = function () { + resolve(); + }; + transaction.onerror = function () { + reject(transaction.error || new Error("IndexedDB transaction failed.")); + }; + transaction.onabort = function () { + reject(transaction.error || new Error("IndexedDB transaction aborted.")); + }; + }); +} + +function mergeOutbox(operations) { + const seen = new Set(); + const merged = []; + for (const operation of normalizeOutbox(operations)) { + if (seen.has(operation.id)) { + continue; + } + seen.add(operation.id); + if (operation.type === "clear") { + merged.length = 0; + } + merged.push(operation); + } + return merged; +} + +function normalizeOutbox(value) { + return (Array.isArray(value) ? value : []).filter(function (operation) { + return operation + && operation.id + && SUPPORTED_OPERATION_TYPES.has(operation.type) + && Number(operation.createdAt) > 0; + }).map(function (operation, index) { + const createdAt = Number(operation.createdAt); + const queueOrder = Number(operation.queueOrder) > 0 + ? Number(operation.queueOrder) + : createdAt * 1000 + index; + return { + id: String(operation.id), + type: String(operation.type), + createdAt, + queueOrder, + payload: operation.payload && typeof operation.payload === "object" ? operation.payload : {}, + }; + }).sort(function (left, right) { + if (left.queueOrder !== right.queueOrder) { + return left.queueOrder - right.queueOrder; + } + if (left.createdAt !== right.createdAt) { + return left.createdAt - right.createdAt; + } + return left.id.localeCompare(right.id); + }); +} + +module.exports = { + OUTBOX_KEY, + createIndexedDBOutboxStore, + createLocalHistoryController, + normalizeOutbox, +}; diff --git a/src/background/native-messaging.js b/src/background/native-messaging.js new file mode 100644 index 0000000..3acd626 --- /dev/null +++ b/src/background/native-messaging.js @@ -0,0 +1,407 @@ +const HOST_NAME = "com.copy_text_without_selecting.companion"; +const PROTOCOL_VERSION = 1; +const MAX_EXTENSION_EVENT_TEXT_BYTES = 512 * 1024; + +function utf8CodePointBytes(codePoint) { + if (codePoint <= 0x7F) { + return 1; + } + if (codePoint <= 0x7FF) { + return 2; + } + if (codePoint <= 0xFFFF) { + return 3; + } + return 4; +} + +function truncateUtf8Bytes(value, maxBytes) { + const text = String(value || ""); + const limit = Math.max(0, Number(maxBytes) || 0); + if (!limit) { + return { text: "", truncated: text.length > 0 }; + } + + let bytes = 0; + for (let index = 0; index < text.length;) { + const codePoint = text.codePointAt(index); + const width = codePoint > 0xFFFF ? 2 : 1; + const nextBytes = utf8CodePointBytes(codePoint); + if (bytes + nextBytes > limit) { + return { + text: text.slice(0, index), + truncated: true, + }; + } + bytes += nextBytes; + index += width; + } + + return { text, truncated: false }; +} + +function createNativeMessagingBridge(utils) { + let port = null; + let lastStatus = { + connected: false, + hostName: HOST_NAME, + lastError: "", + checkedAt: 0, + }; + let nextRequestId = 1; + let connectedHandler = null; + const pending = new Map(); + const recycledPorts = new WeakSet(); + + function canUseNativeMessaging() { + return utils.isExtensionContextValid() + && chrome.runtime + && typeof chrome.runtime.connectNative === "function"; + } + + function connect() { + if (port) { + return port; + } + + if (!canUseNativeMessaging()) { + updateStatus(false, "nativeMessaging API is unavailable."); + return null; + } + + try { + const activePort = chrome.runtime.connectNative(HOST_NAME); + port = activePort; + activePort.onMessage.addListener(handleHostMessage); + activePort.onDisconnect.addListener(function () { + handleDisconnect(activePort); + }); + return port; + } catch (error) { + updateStatus(false, error && error.message ? error.message : String(error)); + port = null; + return null; + } + } + + function handleDisconnect(disconnectedPort) { + const runtimeError = chrome.runtime.lastError; + const message = runtimeError && runtimeError.message ? runtimeError.message : "Native host disconnected."; + const wasRecycled = disconnectedPort && recycledPorts.has(disconnectedPort); + if (wasRecycled) { + recycledPorts.delete(disconnectedPort); + } + port = null; + if (wasRecycled && pending.size === 0) { + return; + } + updateStatus(false, message); + pending.forEach(function (request) { + clearTimeout(request.timeoutId); + request.resolve(createErrorResponse(request.id, "HOST_DISCONNECTED", message, true)); + }); + pending.clear(); + } + + function handleHostMessage(message) { + if (!message || !message.id || !pending.has(message.id)) { + return; + } + + const request = pending.get(message.id); + pending.delete(message.id); + clearTimeout(request.timeoutId); + const wasConnected = lastStatus.connected; + updateStatus(true, ""); + if (!wasConnected) { + notifyConnected(); + } + request.resolve(message); + } + + function notifyConnected() { + if (typeof connectedHandler !== "function") { + return; + } + Promise.resolve().then(function () { + return connectedHandler(); + }).catch(function () { + // Sync retry remains best-effort; pending operations stay in local storage. + }); + } + + function setConnectedHandler(handler) { + connectedHandler = typeof handler === "function" ? handler : null; + } + + function updateStatus(connected, lastError) { + lastStatus = { + connected: !!connected, + hostName: HOST_NAME, + lastError: String(lastError || ""), + checkedAt: Date.now(), + }; + } + + function request(type, payload, timeoutMs) { + const id = "ext-" + Date.now() + "-" + nextRequestId++; + const activePort = connect(); + if (!activePort) { + return Promise.resolve(createErrorResponse(id, "HOST_UNAVAILABLE", lastStatus.lastError || "Native host is unavailable.", true)); + } + + return new Promise(function (resolve) { + const timeoutId = setTimeout(function () { + if (!pending.has(id)) { + return; + } + pending.delete(id); + updateStatus(false, "Native host did not respond in time."); + recyclePort(activePort); + resolve(createErrorResponse(id, "HOST_TIMEOUT", "Native host did not respond in time.", true)); + }, timeoutMs || 1800); + + pending.set(id, { id: id, resolve: resolve, timeoutId: timeoutId }); + try { + activePort.postMessage({ + id: id, + version: PROTOCOL_VERSION, + type: type, + payload: payload || {}, + }); + } catch (error) { + clearTimeout(timeoutId); + pending.delete(id); + updateStatus(false, error && error.message ? error.message : String(error)); + recyclePort(activePort); + resolve(createErrorResponse(id, "HOST_SEND_FAILED", lastStatus.lastError, true)); + } + }); + } + + function recyclePort(activePort) { + if (port !== activePort) { + return; + } + try { + if (activePort && typeof activePort.disconnect === "function") { + recycledPorts.add(activePort); + activePort.disconnect(); + } + } catch (error) { + // The disconnect path is best-effort; status was already updated. + } + port = null; + } + + function createErrorResponse(id, code, message, recoverable) { + return { + id: id, + ok: false, + error: { + code: code, + message: message, + recoverable: recoverable !== false, + }, + }; + } + + function normalizeClipboardEventPayload(payload) { + const text = String((payload && payload.text) || ""); + const boundedText = truncateUtf8Bytes(text, MAX_EXTENSION_EVENT_TEXT_BYTES); + return { + source: String((payload && payload.source) || "web-extension"), + mode: String((payload && payload.mode) || "copy"), + text: boundedText.text, + truncated: boundedText.truncated, + snippet: utils.getTextSnippet(boundedText.text), + format: utils.detectSmartFormat(boundedText.text), + url: String((payload && payload.url) || ""), + hostname: utils.normalizeDomain((payload && payload.hostname) || utils.getHostnameFromUrl((payload && payload.url) || "")), + title: String((payload && payload.title) || ""), + createdAt: Number((payload && payload.createdAt) || Date.now()), + operationAt: Number((payload && payload.operationAt) || Date.now()), + selectionBased: !!(payload && payload.selectionBased), + }; + } + + function getStatus() { + return Object.assign({}, lastStatus); + } + + async function ping() { + const response = await request("PING", { + extensionVersion: chrome.runtime && chrome.runtime.getManifest ? chrome.runtime.getManifest().version : "", + }, 1200); + if (response && response.ok) { + updateStatus(true, ""); + notifyConnected(); + } + return response; + } + + async function forwardClipboardEvent(payload) { + return request("CLIPBOARD_EVENT", normalizeClipboardEventPayload(payload), 1200); + } + + async function listHistory(payload) { + return request("HISTORY_LIST", { + query: String((payload && payload.query) || ""), + limit: Number((payload && payload.limit) || 100), + }, 1800); + } + + async function deleteHistory(payload) { + const text = String((payload && payload.text) || ""); + const boundedText = truncateUtf8Bytes(text, MAX_EXTENSION_EVENT_TEXT_BYTES); + return request("HISTORY_DELETE", { + id: String((payload && payload.id) || ""), + text: boundedText.text, + operationAt: Number((payload && payload.operationAt) || Date.now()), + }, 1800); + } + + async function clearHistory(payload) { + return request("HISTORY_CLEAR", { + operationAt: Number((payload && payload.operationAt) || Date.now()), + }, 2500); + } + + async function pinHistory(payload) { + const text = String((payload && payload.text) || ""); + const boundedText = truncateUtf8Bytes(text, MAX_EXTENSION_EVENT_TEXT_BYTES); + return request("HISTORY_PIN", { + id: String((payload && payload.id) || ""), + text: boundedText.text, + pinned: !!(payload && payload.pinned), + operationAt: Number((payload && payload.operationAt) || Date.now()), + }, 1800); + } + + async function formatPreview(payload) { + return request("FORMAT_PREVIEW", { + text: String((payload && payload.text) || ""), + action: String((payload && payload.action) || ""), + }, 1800); + } + + async function getSettings() { + return request("SETTINGS_GET", {}, 1800); + } + + async function updateSettings(payload) { + return request("SETTINGS_UPDATE", payload || {}, 1800); + } + + async function openApp() { + return request("OPEN_APP", {}, 1200); + } + + function handleRuntimeMessage(message, sender, sendResponse) { + if (!message || typeof message.type !== "string" || !message.type.startsWith("COPY_TEXT_NATIVE_")) { + return false; + } + + if (message.type === "COPY_TEXT_NATIVE_STATUS") { + sendResponse({ ok: true, payload: getStatus() }); + return false; + } + + if (message.type === "COPY_TEXT_NATIVE_PING") { + ping().then(function (response) { + sendResponse(response); + }); + return true; + } + + if (message.type === "COPY_TEXT_NATIVE_CLIPBOARD_EVENT") { + forwardClipboardEvent(message.payload || {}).then(function (response) { + sendResponse(response); + }); + return true; + } + + if (message.type === "COPY_TEXT_NATIVE_HISTORY_LIST") { + listHistory(message.payload || {}).then(function (response) { + sendResponse(response); + }); + return true; + } + + if (message.type === "COPY_TEXT_NATIVE_HISTORY_DELETE") { + deleteHistory(message.payload || {}).then(function (response) { + sendResponse(response); + }); + return true; + } + + if (message.type === "COPY_TEXT_NATIVE_HISTORY_CLEAR") { + clearHistory(message.payload || {}).then(function (response) { + sendResponse(response); + }); + return true; + } + + if (message.type === "COPY_TEXT_NATIVE_HISTORY_PIN") { + pinHistory(message.payload || {}).then(function (response) { + sendResponse(response); + }); + return true; + } + + if (message.type === "COPY_TEXT_NATIVE_FORMAT_PREVIEW") { + formatPreview(message.payload || {}).then(function (response) { + sendResponse(response); + }); + return true; + } + + if (message.type === "COPY_TEXT_NATIVE_SETTINGS_GET") { + getSettings().then(function (response) { + sendResponse(response); + }); + return true; + } + + if (message.type === "COPY_TEXT_NATIVE_SETTINGS_UPDATE") { + updateSettings(message.payload || {}).then(function (response) { + sendResponse(response); + }); + return true; + } + + if (message.type === "COPY_TEXT_NATIVE_OPEN_APP") { + openApp().then(function (response) { + sendResponse(response); + }); + return true; + } + + sendResponse(createErrorResponse("", "UNKNOWN_MESSAGE", "Unknown native bridge message.", false)); + return false; + } + + return { + HOST_NAME: HOST_NAME, + setConnectedHandler: setConnectedHandler, + getStatus: getStatus, + normalizeClipboardEventPayload: normalizeClipboardEventPayload, + ping: ping, + forwardClipboardEvent: forwardClipboardEvent, + listHistory: listHistory, + deleteHistory: deleteHistory, + clearHistory: clearHistory, + pinHistory: pinHistory, + formatPreview: formatPreview, + getSettings: getSettings, + updateSettings: updateSettings, + openApp: openApp, + handleRuntimeMessage: handleRuntimeMessage, + }; +} + +module.exports = { + HOST_NAME, + createNativeMessagingBridge, + truncateUtf8Bytes, +}; diff --git a/src/content/persistence.js b/src/content/persistence.js index 3303140..cc2d996 100644 --- a/src/content/persistence.js +++ b/src/content/persistence.js @@ -6,23 +6,39 @@ function createContentPersistence(context) { } async function saveHistory(text, result, source, isSelectionBased) { - if (!settings().copyHistoryLimit) { + const entry = { + text: text, + snippet: utils.getTextSnippet(text), + source: source, + mode: "copy", + url: window.location.href, + hostname: window.location.hostname, + pinned: false, + replayCount: 0, + lastReplayedAt: isSelectionBased ? Date.now() : 0, + }; + const nativeEvent = { + source: source || "click", + text: text, + url: window.location.href, + hostname: window.location.hostname, + title: document.title || "", + createdAt: Date.now(), + selectionBased: !!isSelectionBased, + }; + + const backgroundSaved = await saveHistoryThroughBackground(entry, nativeEvent); + if (backgroundSaved) { return; } try { + notifyNativeClipboardEvent(nativeEvent); + if (!settings().copyHistoryLimit) { + return; + } const current = await utils.safeStorageGet("local", { copyHistory: [] }); - const nextHistory = utils.pushHistoryEntry(current.copyHistory, { - text: text, - snippet: utils.getTextSnippet(text), - source: source, - mode: "copy", - url: window.location.href, - hostname: window.location.hostname, - pinned: false, - replayCount: 0, - lastReplayedAt: isSelectionBased ? Date.now() : 0, - }, settings().copyHistoryLimit); + const nextHistory = utils.pushHistoryEntry(current.copyHistory, entry, settings().copyHistoryLimit); await utils.safeStorageSet("local", { copyHistory: nextHistory }); } catch (error) { @@ -34,6 +50,43 @@ function createContentPersistence(context) { } } + async function saveHistoryThroughBackground(entry, nativeEvent) { + if (!utils.isExtensionContextValid() || !chrome.runtime || typeof chrome.runtime.sendMessage !== "function") { + return false; + } + + try { + const response = await chrome.runtime.sendMessage({ + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + entry: entry, + nativeEvent: nativeEvent, + limit: settings().copyHistoryLimit, + }, + }); + return !!(response && response.ok); + } catch (error) { + return false; + } + } + + function notifyNativeClipboardEvent(nativeEvent) { + if (!utils.isExtensionContextValid() || !chrome.runtime || typeof chrome.runtime.sendMessage !== "function") { + return; + } + + try { + chrome.runtime.sendMessage({ + type: "COPY_TEXT_NATIVE_CLIPBOARD_EVENT", + payload: nativeEvent, + }).catch(function () { + // The companion is optional; local history remains the fallback. + }); + } catch (error) { + // Ignore optional companion bridge failures. + } + } + async function saveAnalyticsEvent(event) { return saveAnalyticsEvents([event]); } diff --git a/src/options/history-view.js b/src/options/history-view.js index e5f1d2c..eba841c 100644 --- a/src/options/history-view.js +++ b/src/options/history-view.js @@ -77,11 +77,10 @@ function createOptionsHistoryView(context) { async function renderHistoryAndAnalytics() { const current = await context.utils.safeStorageGet("local", { - copyHistory: [], copyAnalytics: context.utils.DEFAULT_ANALYTICS, }); - const history = Array.isArray(current.copyHistory) ? current.copyHistory : []; + const history = await loadHistory(); const analytics = context.utils.normalizeAnalytics(current.copyAnalytics); populateHistoryDomainFilter(history); @@ -184,7 +183,7 @@ function createOptionsHistoryView(context) { deleteButton.type = "button"; deleteButton.textContent = context.ui.t("history_delete_button", "Delete"); deleteButton.addEventListener("click", function () { - deleteHistoryItem(item.id); + deleteHistoryItem(item.id, item.text); }); const fullText = document.createElement("pre"); @@ -210,6 +209,12 @@ function createOptionsHistoryView(context) { top.appendChild(content); wrapper.appendChild(top); wrapper.appendChild(actions); + + const smartActions = createSmartActions(item); + if (smartActions) { + wrapper.appendChild(smartActions); + } + wrapper.appendChild(fullText); return wrapper; @@ -239,6 +244,11 @@ function createOptionsHistoryView(context) { meta.appendChild(createHistoryChip(hostname, "domain-chip")); } + const format = context.utils.normalizeSmartFormat(item.format || context.utils.detectSmartFormat(item.text)); + if (format !== "plain") { + meta.appendChild(createHistoryChip(format.toUpperCase(), "format-" + format)); + } + if (item.replayCount) { meta.appendChild(createHistoryChip("Replay ×" + item.replayCount, "replay-chip")); } @@ -250,35 +260,93 @@ function createOptionsHistoryView(context) { try { await navigator.clipboard.writeText(item.text); context.ui.showStatus(context.ui.t("copy_history_recopied", "Copied from history")); - await recordReplayUsage(item, "historyReplayCopy"); + await recordReplayUsage(item, "historyReplayCopy", item.text); } catch (error) { context.ui.showStatus(error.message || "Clipboard error"); } } - async function deleteHistoryItem(historyId) { - const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); - const nextHistory = context.utils.deleteHistoryEntries(current.copyHistory, [historyId]); + async function deleteHistoryItem(historyId, text) { context.state.selectedHistoryIds.delete(historyId); - await context.utils.safeStorageSet("local", { copyHistory: nextHistory }); + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_DELETE", { + id: historyId, + text: text || "", + }); await renderHistoryAndAnalytics(); context.ui.showStatus(context.ui.t("history_deleted", "History item deleted")); } - async function recordReplayUsage(item, analyticsType) { + function createSmartActions(item) { + const actions = context.utils.getSmartHistoryActions(item); + if (!actions.length) { + return null; + } + + const container = document.createElement("div"); + container.className = "history-smart-actions"; + + actions.forEach(function (action) { + const button = document.createElement("button"); + button.className = "secondary-button smart-action-button"; + button.type = "button"; + button.textContent = action.label; + button.addEventListener("click", function () { + replaySmartAction(item, action); + }); + container.appendChild(button); + }); + + return container; + } + + async function replaySmartAction(item, action) { + const output = context.utils.applySmartAction(item.text, action.id); + if (!output) { + return; + } + + try { + await navigator.clipboard.writeText(output); + context.ui.showStatus(action.label + " copied"); + await recordReplayUsage(Object.assign({}, item, { + text: output, + snippet: context.utils.getTextSnippet(output), + format: context.utils.detectSmartFormat(output), + }), "historyReplayCopy", output); + } catch (error) { + context.ui.showStatus(error.message || "Clipboard error"); + } + } + + async function recordReplayUsage(item, analyticsType, replayText) { const current = await context.utils.safeStorageGet("local", { - copyHistory: [], copyAnalytics: context.utils.DEFAULT_ANALYTICS, }); const hostname = item.hostname || context.utils.getHostnameFromUrl(item.url || ""); - const nextHistory = context.utils.pushHistoryEntry(current.copyHistory, { - text: item.text, - snippet: item.snippet || item.text, + const text = replayText || item.text; + const entry = { + text, + snippet: context.utils.getTextSnippet(text), source: "history", mode: "copy", url: item.url || "", hostname: hostname, - }, context.state.settings.copyHistoryLimit); + format: context.utils.detectSmartFormat(text), + }; + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_ADD", { + entry, + nativeEvent: { + source: "history", + mode: "copy", + text, + url: item.url || "", + hostname, + title: "", + createdAt: Date.now(), + selectionBased: false, + }, + limit: context.state.settings.copyHistoryLimit, + }); const nextAnalytics = context.utils.recordAnalyticsEvent(current.copyAnalytics, { type: analyticsType, hostname: hostname, @@ -286,13 +354,12 @@ function createOptionsHistoryView(context) { }); await context.utils.safeStorageSet("local", { - copyHistory: nextHistory, copyAnalytics: nextAnalytics, }); } async function clearHistory() { - await context.utils.safeStorageSet("local", { copyHistory: [] }); + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_CLEAR"); context.state.selectedHistoryIds.clear(); context.state.bulkSelectionMode = false; await renderHistoryAndAnalytics(); @@ -323,9 +390,16 @@ function createOptionsHistoryView(context) { if (!context.state.selectedHistoryIds.size) { return; } - const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); - const nextHistory = context.utils.deleteHistoryEntries(current.copyHistory, Array.from(context.state.selectedHistoryIds)); - await context.utils.safeStorageSet("local", { copyHistory: nextHistory }); + const history = await loadHistory(); + const items = history.filter(function (item) { + return context.state.selectedHistoryIds.has(item.id); + }).map(function (item) { + return { id: item.id, text: item.text }; + }); + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_DELETE", { + ids: Array.from(context.state.selectedHistoryIds), + items, + }); context.state.selectedHistoryIds.clear(); context.state.bulkSelectionMode = false; await renderHistoryAndAnalytics(); @@ -337,8 +411,8 @@ function createOptionsHistoryView(context) { return; } - const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); - const selectedItems = (Array.isArray(current.copyHistory) ? current.copyHistory : []).filter(function (item) { + const history = await loadHistory(); + const selectedItems = history.filter(function (item) { return item && context.state.selectedHistoryIds.has(item.id); }); const combinedText = selectedItems.map(function (item) { return item.text; }).join("\n\n").trim(); @@ -354,6 +428,33 @@ function createOptionsHistoryView(context) { } } + async function loadHistory() { + const response = await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_LIST", { + limit: Math.max(context.state.settings.copyHistoryLimit || 0, 250), + }, false); + if (response && response.ok && response.payload && Array.isArray(response.payload.history)) { + return response.payload.history; + } + const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); + return Array.isArray(current.copyHistory) ? current.copyHistory : []; + } + + async function sendHistoryMessage(type, payload, throwOnFailure) { + const response = await context.utils.safeChromeAsync(function () { + return chrome.runtime.sendMessage({ + type, + payload: payload || {}, + }); + }, null); + if ((!response || !response.ok) && throwOnFailure !== false) { + const message = response && response.error && response.error.message + ? response.error.message + : "History operation failed."; + throw new Error(message); + } + return response; + } + function updateBulkToolbar() { const hasSelection = context.state.selectedHistoryIds.size > 0; document.getElementById("history_bulk_toggle_button").hidden = context.state.bulkSelectionMode; diff --git a/src/popup/history-view.js b/src/popup/history-view.js index b2f3835..06229a7 100644 --- a/src/popup/history-view.js +++ b/src/popup/history-view.js @@ -1,7 +1,6 @@ function createPopupHistoryView(context) { async function renderHistory() { - const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); - const history = Array.isArray(current.copyHistory) ? current.copyHistory : []; + const history = await loadHistory(); context.elements.historyList.textContent = ""; if (!history.length) { @@ -68,7 +67,7 @@ function createPopupHistoryView(context) { deleteButton.type = "button"; deleteButton.textContent = context.ui.t("history_delete_button", "Delete"); deleteButton.addEventListener("click", function () { - deleteHistoryItem(item.id).catch(function (error) { + deleteHistoryItem(item.id, item.text).catch(function (error) { context.reportPopupError("Deleting popup history failed.", error); }); }); @@ -79,6 +78,11 @@ function createPopupHistoryView(context) { wrapper.appendChild(top); wrapper.appendChild(actions); + const smartActions = createSmartActions(item); + if (smartActions) { + wrapper.appendChild(smartActions); + } + return wrapper; } @@ -103,6 +107,11 @@ function createPopupHistoryView(context) { meta.appendChild(context.ui.createHistoryChip(host, "domain-chip")); } + const format = context.utils.normalizeSmartFormat(item.format || context.utils.detectSmartFormat(item.text)); + if (format !== "plain") { + meta.appendChild(context.ui.createHistoryChip(format.toUpperCase(), "format-" + format)); + } + return meta; } @@ -110,26 +119,85 @@ function createPopupHistoryView(context) { try { await navigator.clipboard.writeText(item.text); context.ui.showStatus(context.ui.t("copy_history_recopied", "Copied from history")); - await recordReplayUsage(item, "historyReplayCopy"); + await recordReplayUsage(item, "historyReplayCopy", item.text); + } catch (error) { + context.ui.showStatus(error.message || "Clipboard error"); + } + } + + function createSmartActions(item) { + const actions = context.utils.getSmartHistoryActions(item); + if (!actions.length) { + return null; + } + + const container = document.createElement("div"); + container.className = "history-smart-actions"; + + actions.forEach(function (action) { + const button = document.createElement("button"); + button.className = "button secondary small smart-action-button"; + button.type = "button"; + button.textContent = action.label; + button.addEventListener("click", function () { + replaySmartAction(item, action).catch(function (error) { + context.reportPopupError("Applying smart history action failed.", error); + }); + }); + container.appendChild(button); + }); + + return container; + } + + async function replaySmartAction(item, action) { + const output = context.utils.applySmartAction(item.text, action.id); + if (!output) { + return; + } + + try { + await navigator.clipboard.writeText(output); + context.ui.showStatus(action.label + " copied"); + await recordReplayUsage(Object.assign({}, item, { + text: output, + snippet: context.utils.getTextSnippet(output), + format: context.utils.detectSmartFormat(output), + }), "historyReplayCopy", output); } catch (error) { context.ui.showStatus(error.message || "Clipboard error"); } } - async function recordReplayUsage(item, analyticsType) { + async function recordReplayUsage(item, analyticsType, replayText) { const current = await context.utils.safeStorageGet("local", { - copyHistory: [], copyAnalytics: context.utils.DEFAULT_ANALYTICS, }); const hostname = item.hostname || context.utils.getHostnameFromUrl(item.url || ""); - const nextHistory = context.utils.pushHistoryEntry(current.copyHistory, { - text: item.text, - snippet: item.snippet || item.text, + const text = replayText || item.text; + const entry = { + text: replayText || item.text, + snippet: context.utils.getTextSnippet(text), source: "history", mode: "copy", url: item.url || "", hostname: hostname, - }, context.getSettings().copyHistoryLimit); + format: context.utils.detectSmartFormat(text), + }; + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_ADD", { + entry, + nativeEvent: { + source: "history", + mode: "copy", + text, + url: item.url || "", + hostname, + title: "", + createdAt: Date.now(), + selectionBased: false, + }, + limit: context.getSettings().copyHistoryLimit, + }); const nextAnalytics = context.utils.recordAnalyticsEvent(current.copyAnalytics, { type: analyticsType, hostname: hostname, @@ -137,25 +205,52 @@ function createPopupHistoryView(context) { }); await context.utils.safeStorageSet("local", { - copyHistory: nextHistory, copyAnalytics: nextAnalytics, }); } async function clearHistory() { - await context.utils.safeStorageSet("local", { copyHistory: [] }); + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_CLEAR"); await renderHistory(); context.ui.showStatus(context.ui.t("copy_history_cleared", "History cleared")); } - async function deleteHistoryItem(historyId) { - const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); - const nextHistory = context.utils.deleteHistoryEntries(current.copyHistory, [historyId]); - await context.utils.safeStorageSet("local", { copyHistory: nextHistory }); + async function deleteHistoryItem(historyId, text) { + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_DELETE", { + id: historyId, + text: text || "", + }); await renderHistory(); context.ui.showStatus(context.ui.t("history_deleted", "History item deleted")); } + async function loadHistory() { + const response = await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_LIST", { + limit: context.getSettings().copyHistoryLimit || 100, + }, false); + if (response && response.ok && response.payload && Array.isArray(response.payload.history)) { + return response.payload.history; + } + const current = await context.utils.safeStorageGet("local", { copyHistory: [] }); + return Array.isArray(current.copyHistory) ? current.copyHistory : []; + } + + async function sendHistoryMessage(type, payload, throwOnFailure) { + const response = await context.utils.safeChromeAsync(function () { + return chrome.runtime.sendMessage({ + type, + payload: payload || {}, + }); + }, null); + if ((!response || !response.ok) && throwOnFailure !== false) { + const message = response && response.error && response.error.message + ? response.error.message + : "History operation failed."; + throw new Error(message); + } + return response; + } + return { renderHistory, clearHistory, diff --git a/src/popup/index.js b/src/popup/index.js index 14ce0da..f7133cd 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -21,6 +21,8 @@ const { createPopupHistoryView } = require("./history-view.js"); avoidEditable: document.getElementById("popup_avoid_editable"), toastDuration: document.getElementById("popup_toast_duration"), copyHistoryLimit: document.getElementById("popup_copy_history_limit"), + nativeStatus: document.getElementById("native_status"), + openCompanion: document.getElementById("open_companion"), openOptions: document.getElementById("open_options"), clearHistory: document.getElementById("clear_history"), historyList: document.getElementById("history_list"), @@ -66,6 +68,7 @@ const { createPopupHistoryView } = require("./history-view.js"); await context.state.restoreSettings(); context.ui.applyMessages(); await Promise.all([context.state.detectCurrentSite(), context.historyView.renderHistory()]); + refreshNativeStatus(); } catch (error) { if (reportPopupError("Initializing popup failed.", error)) { return; @@ -94,6 +97,13 @@ const { createPopupHistoryView } = require("./history-view.js"); reportPopupError("Opening the options page failed.", error); }); }); + if (elements.openCompanion) { + elements.openCompanion.addEventListener("click", function () { + openNativeCompanion().catch(function (error) { + reportPopupError("Opening the native companion failed.", error); + }); + }); + } utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { if (!isExtensionUsable()) { @@ -117,4 +127,47 @@ const { createPopupHistoryView } = require("./history-view.js"); } }); } + + async function refreshNativeStatus() { + if (!elements.nativeStatus || !isExtensionUsable()) { + return; + } + + const response = await utils.safeChromeAsync(function () { + return chrome.runtime.sendMessage({ type: "COPY_TEXT_NATIVE_PING" }); + }, null); + + const connected = !!(response && response.ok); + elements.nativeStatus.classList.toggle("connected", connected); + elements.nativeStatus.textContent = connected + ? "Companion: connected" + : "Companion: local mode"; + if (response && response.error && response.error.message) { + elements.nativeStatus.title = response.error.message; + } + } + + async function openNativeCompanion() { + if (!isExtensionUsable()) { + return; + } + + const response = await utils.safeChromeAsync(function () { + return chrome.runtime.sendMessage({ type: "COPY_TEXT_NATIVE_OPEN_APP" }); + }, null); + + const opened = !!(response && response.ok && response.payload && response.payload.opened); + if (opened) { + elements.nativeStatus.classList.add("connected"); + elements.nativeStatus.textContent = "Companion: opened"; + elements.nativeStatus.title = ""; + return; + } + + elements.nativeStatus.classList.remove("connected"); + elements.nativeStatus.textContent = "Companion: local mode"; + if (response && response.error && response.error.message) { + elements.nativeStatus.title = response.error.message; + } + } })(); diff --git a/src/shared/core.js b/src/shared/core.js index 755d756..cf593a8 100644 --- a/src/shared/core.js +++ b/src/shared/core.js @@ -34,8 +34,12 @@ var DEFAULT_SETTINGS = { var SUPPORTED_META_KEYS = ["Alt", "Ctrl", "Shift"]; var SUPPORTED_UI_LANGUAGES = ["auto", "en", "vi"]; + var SMART_FORMATS = ["plain", "json", "sql", "jwt", "timestamp", "date", "base64"]; var DEFAULT_COPY_HISTORY_LIMIT = 20; var MAX_COPY_HISTORY_LIMIT = 9999; + var SQL_START_WORDS = ["select", "insert", "update", "delete", "with", "create", "alter", "drop"]; + var SQL_KEYWORDS = ["SELECT", "FROM", "WHERE", "AND", "OR", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "ON", "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "TABLE", "AS", "DISTINCT", "NULL", "NOT", "IN", "LIKE", "BETWEEN", "UNION", "ALL"]; + var SQL_CLAUSES = ["GROUP BY", "ORDER BY", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "FROM", "WHERE", "HAVING", "LIMIT", "JOIN", "UNION"]; var VI_MESSAGES = { save_status_saved: "Đã lưu", settings_eyebrow: "Thiết lập", @@ -43,7 +47,7 @@ var DEFAULT_SETTINGS = { settings_intro: "Cấu hình thao tác copy, hover preview, chế độ an toàn và lịch sử trên các website.", tab_general: "Chung", tab_sites: "Website", tab_feedback: "Phản hồi", tab_language: "Ngôn ngữ", tab_history: "Lịch sử", general_section_title: "Điều khiển chung", sites_section_title: "Domain loại trừ", - meta_key_label: "Phím thao tác copy", meta_key_help: "Chế độ append dùng thêm phím Shift cùng với phím copy bạn chọn.", + meta_key_label: "Phím thao tác copy", meta_key_help: "Giữ phím copy rồi click để copy nhanh nội dung trên trang.", preview_enabled_label: "Hover preview", preview_enabled_help: "Hiển thị khung nét đứt trên phần tử đích khi giữ phím copy.", avoid_editable_label: "Bỏ qua vùng có thể chỉnh sửa", avoid_editable_help: "Tránh kích hoạt copy trong editor contenteditable và vùng nhập liệu nâng cao.", keyboard_shortcut_enabled_label: "Chế độ phím tắt", keyboard_shortcut_enabled_help: "Cho phép dùng phím tắt của extension để copy phần tử đang hover hoặc đang focus mà không cần click.", @@ -56,29 +60,29 @@ var DEFAULT_SETTINGS = { language_title: "Ngôn ngữ", ui_language_label: "Ngôn ngữ extension", ui_language_help: "Auto dùng ngôn ngữ trình duyệt. Có thể ép sang English hoặc Tiếng Việt.", ui_language_auto: "Tự động", ui_language_en: "English", ui_language_vi: "Tiếng Việt", history_title: "Lịch sử copy", copy_history_limit_label: "Số mục lịch sử lưu lại", copy_history_limit_help: "Số mục copy gần đây sẽ được giữ trong bộ nhớ cục bộ.", - copy_history_empty: "Chưa có mục copy nào.", copy_history_recopied: "Đã copy lại từ lịch sử", copy_history_appended: "Đã append từ lịch sử", copy_history_cleared: "Đã xóa lịch sử", + copy_history_empty: "Chưa có mục copy nào.", copy_history_recopied: "Đã copy lại từ lịch sử", copy_history_appended: "Đã copy mục trong lịch sử", copy_history_cleared: "Đã xóa lịch sử", copy_history_source_click: "Click từ extension", copy_history_source_shortcut: "Phím tắt extension", copy_history_source_history: "Replay lịch sử", copy_history_source_native: "Copy mặc định", - history_copy_button: "Copy lại", history_append_button: "Append", history_delete_button: "Xóa", history_expand_button: "Xem đầy đủ", history_collapse_button: "Ẩn bớt", + history_copy_button: "Copy lại", history_append_button: "Copy thông minh", history_delete_button: "Xóa", history_expand_button: "Xem đầy đủ", history_collapse_button: "Ẩn bớt", history_pin_button: "Ghim", history_unpin_button: "Bỏ ghim", history_bulk_select_button: "Chọn nhiều mục", history_bulk_cancel_button: "Hủy chọn", - history_bulk_delete_button: "Xóa mục đã chọn", history_bulk_copy_button: "Copy mục đã chọn", history_bulk_append_button: "Append mục đã chọn", + history_bulk_delete_button: "Xóa mục đã chọn", history_bulk_copy_button: "Copy mục đã chọn", history_bulk_append_button: "Copy gộp mục đã chọn", history_search_label: "Tìm trong lịch sử", history_search_placeholder: "Tìm theo nội dung hoặc hostname", history_filter_source_label: "Nguồn", history_filter_mode_label: "Chế độ", history_filter_domain_label: "Domain", history_filter_all: "Tất cả", history_filter_click: "Click từ extension", history_filter_shortcut: "Phím tắt extension", history_filter_history: "Replay lịch sử", history_filter_native: "Copy mặc định", - history_filter_copy: "Copy", history_filter_append: "Append", history_filter_append_fallback: "Append fallback", history_filter_pinned_label: "Chỉ hiện mục ghim", + history_filter_copy: "Copy", history_filter_append: "Copy thông minh", history_filter_append_fallback: "Copy thay thế", history_filter_pinned_label: "Chỉ hiện mục ghim", history_group_label: "Nhóm theo", history_group_none: "Không nhóm", history_group_domain: "Domain", history_group_source: "Nguồn", history_group_date: "Ngày", history_sort_label: "Sắp xếp", history_sort_newest: "Mới nhất", history_sort_oldest: "Cũ nhất", history_sort_replayed: "Replay nhiều nhất", history_sort_pinned: "Ưu tiên ghim", history_recency_now: "Vừa xong", history_no_results: "Không có mục lịch sử phù hợp.", - analytics_title: "Phân tích nội bộ", analytics_total_actions: "Tổng hành động", analytics_append_actions: "Lượt append", analytics_native_actions: "Lượt copy mặc định", + analytics_title: "Phân tích nội bộ", analytics_total_actions: "Tổng hành động", analytics_append_actions: "Lượt copy theo selection", analytics_native_actions: "Lượt copy mặc định", analytics_selection_actions: "Lượt copy theo selection", analytics_shortcut_actions: "Lượt dùng phím tắt", analytics_blocked_actions: "Lượt bị chặn", analytics_toast_events: "Lượt toast hiển thị", analytics_top_domains: "Domain dùng nhiều nhất", analytics_empty_domains: "Chưa có hoạt động domain nào.", analytics_reset_done: "Đã xóa analytics", reset_analytics_button: "Xóa analytics", history_deleted: "Đã xóa mục lịch sử", history_selection_cleared: "Đã bỏ chọn", history_bulk_done: "Đã hoàn thành thao tác hàng loạt", popup_eyebrow: "Điều khiển nhanh", popup_title: "Copy text with Alt-Click", popup_subtitle: "Điều chỉnh nhanh website hiện tại và các thiết lập hay dùng mà không cần mở trang settings đầy đủ.", popup_site_label: "Website hiện tại", popup_modifier_label: "Phím copy", popup_preview_label: "Hover preview", popup_preview_hint: "Hiển thị overlay trên phần tử đích khi giữ phím copy.", - popup_safe_label: "Bỏ qua editor", popup_safe_hint: "Tránh copy trong contenteditable và rich text editor.", popup_duration_label: "Thời lượng phản hồi", popup_append_hint: "Chế độ append vẫn dùng được với Shift + phím copy + Click.", + popup_safe_label: "Bỏ qua editor", popup_safe_hint: "Tránh copy trong contenteditable và rich text editor.", popup_duration_label: "Thời lượng phản hồi", popup_append_hint: "Điều chỉnh nhanh hành vi copy, độ an toàn và lịch sử gần đây.", popup_site_active: "Đang bật", popup_site_excluded: "Đã loại trừ", popup_site_unsupported: "Không hỗ trợ", popup_site_unknown: "Trang này không thể chạy script", popup_toggle_include: "Cho phép website này", popup_toggle_exclude: "Loại trừ website này", popup_history_title: "Lịch sử gần đây", popup_history_empty: "Chưa có mục copy gần đây.", clear_history: "Xóa lịch sử", open_options: "Mở cài đặt đầy đủ", - toast_copied: "Đã copy!", toast_appended: "Đã append!", toast_append_fallback: "Đã copy thay thế", + toast_copied: "Đã copy!", toast_appended: "Đã copy!", toast_append_fallback: "Đã copy thay thế", shortcut_unavailable: "Không có phần tử hover/focus nào để copy.", unsupported_surface_status: "Đã bỏ qua vùng đang chỉnh sửa" }; @@ -192,6 +196,358 @@ var DEFAULT_SETTINGS = { return collapsed.length > limit ? collapsed.slice(0, limit - 3) + "..." : collapsed; } + function detectSmartFormat(text) { + var trimmed = String(text || "").trim(); + var lower = trimmed.toLowerCase(); + var firstCharacter = trimmed.charAt(0); + if (!trimmed) return "plain"; + if (isJwtText(trimmed)) return "jwt"; + if (detectTimestamp(trimmed).ok) return "timestamp"; + if (isDateLikeText(trimmed)) return "date"; + if ((firstCharacter === "{" || firstCharacter === "[") && isValidJson(trimmed)) return "json"; + if (SQL_START_WORDS.some(function (keyword) { + return lower === keyword || lower.startsWith(keyword + " "); + })) return "sql"; + if (looksBase64(trimmed)) return "base64"; + return "plain"; + } + + function normalizeSmartFormat(format) { + return SMART_FORMATS.includes(format) ? format : "plain"; + } + + function isValidJson(text) { + try { + JSON.parse(text); + return true; + } catch (error) { + return false; + } + } + + function prettyJson(text) { + try { + return JSON.stringify(JSON.parse(String(text || "")), null, 2); + } catch (error) { + return String(text || ""); + } + } + + function minifyJson(text) { + try { + return JSON.stringify(JSON.parse(String(text || ""))); + } catch (error) { + return String(text || ""); + } + } + + function formatSql(text) { + return transformSqlCode(String(text || ""), function (segment) { + var formatted = collapseSqlCodeSegment(segment); + formatted = replaceWordPrefixInsensitive(formatted, "select", "SELECT"); + formatted = replaceWordPrefixInsensitive(formatted, "insert", "INSERT"); + formatted = replaceWordPrefixInsensitive(formatted, "update", "UPDATE"); + formatted = replaceWordPrefixInsensitive(formatted, "delete", "DELETE"); + formatted = replaceWordPrefixInsensitive(formatted, "with", "WITH"); + formatted = replacePhrasePrefixInsensitive(formatted, "group by", "GROUP BY"); + formatted = replacePhrasePrefixInsensitive(formatted, "order by", "ORDER BY"); + formatted = replacePhrasePrefixInsensitive(formatted, "left join", "LEFT JOIN"); + formatted = replacePhrasePrefixInsensitive(formatted, "right join", "RIGHT JOIN"); + formatted = replacePhrasePrefixInsensitive(formatted, "inner join", "INNER JOIN"); + formatted = replaceWordPrefixInsensitive(formatted, "from", "FROM"); + formatted = replaceWordPrefixInsensitive(formatted, "where", "WHERE"); + formatted = replaceWordPrefixInsensitive(formatted, "having", "HAVING"); + formatted = replaceWordPrefixInsensitive(formatted, "limit", "LIMIT"); + formatted = replaceWordPrefixInsensitive(formatted, "join", "JOIN"); + formatted = replaceWordPrefixInsensitive(formatted, "values", "VALUES"); + formatted = replaceWordPrefixInsensitive(formatted, "set", "SET"); + SQL_KEYWORDS.forEach(function (keyword) { + formatted = formatted.replace(new RegExp("\\b" + keyword + "\\b", "gi"), keyword); + }); + SQL_CLAUSES.forEach(function (clause) { + formatted = formatted.replace(new RegExp("\\s+" + clause.replace(/\s+/g, "\\s+"), "g"), "\n" + clause); + }); + return formatted; + }).trim(); + } + + function minifySql(text) { + return transformSqlCode(String(text || ""), collapseSqlCodeSegment).trim(); + } + + function collapseSpaces(text) { + return String(text || "").replace(/\s+/g, " ").trim(); + } + + function isJwtText(text) { + var parts = String(text || "").trim().split("."); + if (parts.length !== 3 || !parts[0].startsWith("eyJ")) return false; + try { + return isValidJson(decodeBase64Url(parts[0])) && isValidJson(decodeBase64Url(parts[1])); + } catch (error) { + return false; + } + } + + function decodeJwt(text) { + var parts = String(text || "").trim().split("."); + if (parts.length !== 3) return ""; + try { + var header = prettyJson(decodeBase64Url(parts[0])); + var payload = prettyJson(decodeBase64Url(parts[1])); + return "HEADER\n" + header + "\n\nPAYLOAD\n" + payload; + } catch (error) { + return String(text || ""); + } + } + + function decodeBase64Url(value) { + var normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/"); + while (normalized.length % 4) normalized += "="; + return decodeBase64(normalized); + } + + function encodeBase64(text) { + if (typeof Buffer !== "undefined") { + return Buffer.from(String(text || ""), "utf8").toString("base64"); + } + if (typeof btoa === "function") { + return btoa(unescape(encodeURIComponent(String(text || "")))); + } + return String(text || ""); + } + + function decodeBase64(text) { + if (typeof Buffer !== "undefined") { + return Buffer.from(String(text || ""), "base64").toString("utf8"); + } + if (typeof atob === "function") { + return decodeURIComponent(escape(atob(String(text || "")))); + } + return String(text || ""); + } + + function safeDecodeBase64(text) { + try { + return looksBase64(text) ? decodeBase64(text) : String(text || ""); + } catch (error) { + return String(text || ""); + } + } + + function looksBase64(text) { + var trimmed = String(text || "").trim(); + if (trimmed.length < 12 || trimmed.length % 4 !== 0 || /[\s]/.test(trimmed)) return false; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(trimmed)) return false; + try { + var decoded = decodeBase64(trimmed); + if (!decoded || /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(decoded)) return false; + return encodeBase64(decoded) === trimmed; + } catch (error) { + return false; + } + } + + function transformSqlCode(text, transform) { + var output = ""; + var code = ""; + function flushCode() { + if (!code) return; + output += transform(code); + code = ""; + } + for (var index = 0; index < text.length;) { + if (text.slice(index, index + 2) === "--") { + flushCode(); + var lineEnd = text.indexOf("\n", index); + if (lineEnd < 0) { + output += text.slice(index); + break; + } + output += text.slice(index, lineEnd + 1); + index = lineEnd + 1; + continue; + } + if (text.slice(index, index + 2) === "/*") { + flushCode(); + var blockEnd = text.indexOf("*/", index + 2); + if (blockEnd < 0) { + output += text.slice(index); + break; + } + output += text.slice(index, blockEnd + 2); + index = blockEnd + 2; + continue; + } + if (text[index] === "'" || text[index] === "\"") { + flushCode(); + var quote = text[index]; + var start = index++; + while (index < text.length) { + if (text[index] === quote) { + if (text[index + 1] === quote) { + index += 2; + continue; + } + index++; + break; + } + index++; + } + output += text.slice(start, index); + continue; + } + code += text[index++]; + } + flushCode(); + return output; + } + + function collapseSqlCodeSegment(segment) { + if (!String(segment || "").trim()) return segment ? " " : ""; + var collapsed = String(segment || "").replace(/\s+/g, " ").trim(); + if (/^\s/.test(segment)) collapsed = " " + collapsed; + if (/\s$/.test(segment)) collapsed += " "; + return collapsed; + } + + function replaceWordPrefixInsensitive(input, needle, replacement) { + return replacePhrasePrefixInsensitive(input, needle, replacement); + } + + function replacePhrasePrefixInsensitive(input, needle, replacement) { + var value = String(input || ""); + var leading = value.match(/^\s*/)[0]; + var rest = value.slice(leading.length); + if (rest.slice(0, needle.length).toLowerCase() !== needle.toLowerCase()) return input; + var next = rest.charAt(needle.length); + if (next && /[A-Za-z0-9_]/.test(next)) return input; + return leading + replacement + rest.slice(needle.length); + } + + function splitWords(text) { + var expanded = String(text || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2"); + return expanded.split(/[\s_-]+/).map(function (word) { + return word.trim().toLowerCase(); + }).filter(Boolean); + } + + function toCamelCase(text) { + var words = splitWords(text); + if (!words.length) return String(text || ""); + return words.map(function (word, index) { + return index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1); + }).join(""); + } + + function toDelimitedCase(text, separator) { + var words = splitWords(text); + return words.length ? words.join(separator) : String(text || ""); + } + + function detectTimestamp(text) { + var trimmed = String(text || "").trim(); + if (!/^\d{10}(\d{3})?$/.test(trimmed)) { + return { ok: false, date: null, isMilliseconds: false }; + } + var isMilliseconds = trimmed.length === 13; + var numeric = Number(trimmed); + var date = new Date(isMilliseconds ? numeric : numeric * 1000); + var year = date.getFullYear(); + return { + ok: year > 1975 && year < 2100, + date: date, + isMilliseconds: isMilliseconds + }; + } + + function timestampToDate(text) { + var detected = detectTimestamp(text); + if (!detected.ok) return String(text || ""); + return formatLocalDateTime(detected.date); + } + + function isDateLikeText(text) { + var trimmed = String(text || "").trim(); + if (!trimmed || /^\d+$/.test(trimmed)) return false; + if (!/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?|^\d{2}\/\d{2}\/\d{4}(?: \d{2}:\d{2}(?::\d{2})?)?$/.test(trimmed)) { + return false; + } + return Number.isFinite(Date.parse(trimmed.replace(" ", "T"))); + } + + function dateToTimestamp(text) { + var trimmed = String(text || "").trim(); + var parsed = Date.parse(trimmed.replace(" ", "T")); + if (!Number.isFinite(parsed)) { + return String(text || ""); + } + return String(Math.floor(parsed / 1000)); + } + + function formatLocalDateTime(date) { + function pad(value) { + return String(value).padStart(2, "0"); + } + return [ + date.getFullYear(), + "-", + pad(date.getMonth() + 1), + "-", + pad(date.getDate()), + " ", + pad(date.getHours()), + ":", + pad(date.getMinutes()), + ":", + pad(date.getSeconds()) + ].join(""); + } + + function applySmartAction(text, actionId) { + var value = String(text || ""); + switch (actionId) { + case "prettyJson": return prettyJson(value); + case "minifyJson": return minifyJson(value); + case "formatSql": return formatSql(value); + case "minifySql": return minifySql(value); + case "decodeJwt": return decodeJwt(value); + case "timestampToDate": return timestampToDate(value); + case "dateToTimestamp": return dateToTimestamp(value); + case "upper": return value.toUpperCase(); + case "lower": return value.toLowerCase(); + case "camel": return toCamelCase(value); + case "snake": return toDelimitedCase(value, "_"); + case "kebab": return toDelimitedCase(value, "-"); + case "base64Encode": return encodeBase64(value); + case "base64Decode": return safeDecodeBase64(value); + default: return value; + } + } + + function getSmartHistoryActions(entryOrText) { + var text = typeof entryOrText === "string" ? entryOrText : String((entryOrText && entryOrText.text) || ""); + var format = normalizeSmartFormat((entryOrText && entryOrText.format) || detectSmartFormat(text)); + var actions = []; + if (format === "json") actions.push({ id: "prettyJson", label: "Pretty" }, { id: "minifyJson", label: "Minify" }); + if (format === "sql") actions.push({ id: "formatSql", label: "Format SQL" }, { id: "minifySql", label: "Minify" }); + if (format === "jwt") actions.push({ id: "decodeJwt", label: "Decode JWT (unverified)" }); + if (format === "timestamp") actions.push({ id: "timestampToDate", label: "To Date" }); + if (format === "date") actions.push({ id: "dateToTimestamp", label: "To Epoch" }); + if (format === "base64") actions.push({ id: "base64Decode", label: "Decode b64" }); + if (text.trim()) { + actions.push( + { id: "upper", label: "UPPER" }, + { id: "lower", label: "lower" }, + { id: "camel", label: "camel" }, + { id: "snake", label: "snake" }, + { id: "kebab", label: "kebab" }, + { id: "base64Encode", label: "b64" } + ); + } + return actions; + } + function getLanguage(settingsOrLanguage, browserLanguage) { var requested = typeof settingsOrLanguage === "string" ? settingsOrLanguage : normalizeUiLanguage((settingsOrLanguage || {}).uiLanguage); if (requested !== "auto") return requested; @@ -210,8 +566,8 @@ var DEFAULT_SETTINGS = { function normalizeHistoryEntry(entry) { if (!entry) return null; - var normalizedText = String(entry.text || "").trim(); - if (!normalizedText) return null; + var normalizedText = String(entry.text === undefined || entry.text === null ? "" : entry.text); + if (!normalizedText.trim()) return null; return { id: String(entry.id || (Date.now() + "-" + Math.random().toString(16).slice(2))), text: normalizedText, @@ -221,6 +577,7 @@ var DEFAULT_SETTINGS = { url: String(entry.url || ""), hostname: normalizeDomain(entry.hostname || getHostnameFromUrl(entry.url || "")), mode: String(entry.mode || "copy"), + format: normalizeSmartFormat(entry.format || detectSmartFormat(normalizedText)), pinned: !!entry.pinned, replayCount: Number(entry.replayCount || 0), lastReplayedAt: Number(entry.lastReplayedAt || 0) @@ -229,9 +586,9 @@ var DEFAULT_SETTINGS = { function pushHistoryEntry(entries, entry, limit) { var nextEntries = (Array.isArray(entries) ? entries : []).map(normalizeHistoryEntry).filter(Boolean); - var maxItems = normalizeCopyHistoryLimit(limit || DEFAULT_COPY_HISTORY_LIMIT); + var maxItems = normalizeCopyHistoryLimit(limit === undefined || limit === null || limit === "" ? DEFAULT_COPY_HISTORY_LIMIT : limit); var normalizedEntry = normalizeHistoryEntry(entry); - if (!normalizedEntry || maxItems === 0) return nextEntries.slice(0, maxItems); + if (!normalizedEntry || maxItems === 0) return trimHistoryEntries(nextEntries, maxItems); var existing = nextEntries.find(function (item) { return item.text === normalizedEntry.text; }); if (existing) { @@ -247,15 +604,28 @@ var DEFAULT_SETTINGS = { } else { nextEntries.unshift(normalizedEntry); } - return nextEntries.slice(0, maxItems); + return trimHistoryEntries(nextEntries, maxItems); } function updateHistoryEntry(entries, historyId, updater, limit) { - return (Array.isArray(entries) ? entries : []).map(normalizeHistoryEntry).filter(Boolean).map(function (entry) { + var maxItems = normalizeCopyHistoryLimit(limit === undefined || limit === null || limit === "" ? DEFAULT_COPY_HISTORY_LIMIT : limit); + var updatedEntries = (Array.isArray(entries) ? entries : []).map(normalizeHistoryEntry).filter(Boolean).map(function (entry) { if (entry.id !== historyId) return entry; var updated = typeof updater === "function" ? updater(Object.assign({}, entry)) : entry; return normalizeHistoryEntry(Object.assign({}, entry, updated || {})); - }).slice(0, normalizeCopyHistoryLimit(limit || DEFAULT_COPY_HISTORY_LIMIT)); + }).filter(Boolean); + return trimHistoryEntries(updatedEntries, maxItems); + } + + function trimHistoryEntries(entries, maxUnpinned) { + if (maxUnpinned === 0) return []; + var unpinned = 0; + return (Array.isArray(entries) ? entries : []).filter(function (entry) { + if (entry.pinned) return true; + if (unpinned >= maxUnpinned) return false; + unpinned += 1; + return true; + }); } function deleteHistoryEntries(entries, ids) { @@ -568,6 +938,7 @@ module.exports = { DEFAULT_ANALYTICS: DEFAULT_ANALYTICS, SUPPORTED_META_KEYS: SUPPORTED_META_KEYS, SUPPORTED_UI_LANGUAGES: SUPPORTED_UI_LANGUAGES, + SMART_FORMATS: SMART_FORMATS, UI_MESSAGES: VI_MESSAGES, normalizeDomain: normalizeDomain, normalizeExcludedDomains: normalizeExcludedDomains, @@ -585,6 +956,17 @@ module.exports = { toggleDomain: toggleDomain, getHostnameFromUrl: getHostnameFromUrl, getTextSnippet: getTextSnippet, + detectSmartFormat: detectSmartFormat, + normalizeSmartFormat: normalizeSmartFormat, + prettyJson: prettyJson, + minifyJson: minifyJson, + formatSql: formatSql, + minifySql: minifySql, + decodeJwt: decodeJwt, + timestampToDate: timestampToDate, + dateToTimestamp: dateToTimestamp, + applySmartAction: applySmartAction, + getSmartHistoryActions: getSmartHistoryActions, getLanguage: getLanguage, translate: translate, normalizeHistoryEntry: normalizeHistoryEntry, diff --git a/tests/local-history-failure.test.js b/tests/local-history-failure.test.js new file mode 100644 index 0000000..c7f0508 --- /dev/null +++ b/tests/local-history-failure.test.js @@ -0,0 +1,124 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const core = require("../src/shared/core.js"); +const { createLocalHistoryController } = require("../src/background/local-history.js"); + +function sendRuntimeMessage(controller, message) { + return new Promise(function (resolve) { + controller.handleRuntimeMessage(message, {}, resolve); + }); +} + +test("local history add reports failure when local and native writes both fail", async function () { + const utils = Object.assign({}, core, { + safeStorageGet: async function (area, defaults) { + return Object.assign({}, defaults, { copyHistory: [], copyHistorySyncOutbox: [] }); + }, + safeStorageSet: async function () { + return false; + }, + }); + const nativeBridge = { + forwardClipboardEvent: async function () { + throw new Error("native unavailable"); + }, + }; + const controller = createLocalHistoryController(utils, nativeBridge); + + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 10, + entry: { text: "lost", createdAt: 1 }, + nativeEvent: { text: "lost" }, + }, + }); + + assert.equal(response.ok, false); + assert.equal(response.error.code, "HISTORY_ADD_FAILED"); + assert.equal(response.payload.local.ok, false); + assert.equal(response.payload.native.ok, false); +}); + +test("failed outbox operation is retained for retry", async function () { + let state = { + copyHistory: [], + copyHistorySyncOutbox: [], + }; + const utils = Object.assign({}, core, { + safeStorageGet: async function (area, defaults) { + return Object.assign({}, defaults, structuredClone(state)); + }, + safeStorageSet: async function (area, value) { + state = Object.assign({}, state, structuredClone(value)); + return true; + }, + }); + const nativeBridge = { + setConnectedHandler: function () {}, + forwardClipboardEvent: async function () { + return { + ok: false, + error: { code: "STORE_WRITE_FAILED", message: "disk full", recoverable: true }, + }; + }, + }; + const controller = createLocalHistoryController(utils, nativeBridge); + + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 10, + entry: { id: "local-a", text: "retry me", createdAt: 1 }, + nativeEvent: { text: "retry me", createdAt: 1 }, + }, + }); + + assert.equal(state.copyHistorySyncOutbox.length, 1); + assert.equal(state.copyHistorySyncOutbox[0].type, "upsert"); +}); + +test("local cache success does not hide failed outbox and native writes", async function () { + let state = { + copyHistory: [], + copyHistorySyncOutbox: [], + }; + const utils = Object.assign({}, core, { + safeStorageGet: async function (area, defaults) { + return Object.assign({}, defaults, structuredClone(state)); + }, + safeStorageSet: async function (area, value) { + state = Object.assign({}, state, structuredClone(value)); + return true; + }, + }); + const nativeBridge = { + forwardClipboardEvent: async function () { + return { + ok: false, + error: { code: "HOST_UNAVAILABLE", message: "offline", recoverable: true }, + }; + }, + }; + const controller = createLocalHistoryController(utils, nativeBridge, { + outboxStore: { + list: async function () { return []; }, + append: async function () { return false; }, + replace: async function () { return false; }, + remove: async function () { return false; }, + }, + }); + + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 10, + entry: { id: "local-only", text: "not synced", createdAt: 1 }, + nativeEvent: { text: "not synced", createdAt: 1 }, + }, + }); + + assert.equal(response.ok, false); + assert.equal(response.error.code, "HISTORY_ADD_FAILED"); + assert.equal(state.copyHistory.length, 1); +}); diff --git a/tests/local-history.test.js b/tests/local-history.test.js new file mode 100644 index 0000000..86f0f9e --- /dev/null +++ b/tests/local-history.test.js @@ -0,0 +1,658 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const core = require("../src/shared/core.js"); +const { + createIndexedDBOutboxStore, + createLocalHistoryController, +} = require("../src/background/local-history.js"); + +function sendRuntimeMessage(controller, message) { + return new Promise(function (resolve) { + controller.handleRuntimeMessage(message, {}, resolve); + }); +} + +function createStorage(initial) { + const input = Object.assign({}, initial || {}); + const syncInput = Object.assign({}, input.sync || {}); + const syncThrows = !!input.syncThrows; + delete input.sync; + delete input.syncThrows; + let state = Object.assign({ + copyHistory: [], + copyHistorySyncOutbox: [], + }, input); + let syncState = Object.assign({}, core.DEFAULT_SETTINGS, syncInput); + + return { + utils: Object.assign({}, core, { + safeStorageGet: async function (area, defaults) { + if (area === "local") { + return Object.assign({}, defaults, structuredClone(state)); + } + if (area === "sync") { + if (syncThrows) { + throw new Error("sync storage unavailable"); + } + return Object.assign({}, defaults, structuredClone(syncState)); + } + throw new Error("Unexpected storage area: " + area); + }, + safeStorageSet: async function (area, value) { + if (area === "local") { + state = Object.assign({}, state, structuredClone(value)); + return true; + } + if (area === "sync") { + syncState = Object.assign({}, syncState, structuredClone(value)); + return true; + } + throw new Error("Unexpected storage area: " + area); + }, + }), + read: function () { + return structuredClone(state); + }, + }; +} + +function createNativeBridge(options) { + const config = options || {}; + let connected = config.connected !== false; + let connectedHandler = null; + const calls = []; + + async function respond(type, payload) { + calls.push({ type, payload: structuredClone(payload || {}) }); + if (!connected) { + return { + ok: false, + error: { code: "HOST_UNAVAILABLE", message: "offline", recoverable: true }, + }; + } + return { + ok: true, + payload: type === "CLIPBOARD_EVENT" + ? { stored: true, id: "native-" + payload.text } + : {}, + }; + } + + return { + calls, + setConnectedHandler: function (handler) { + connectedHandler = handler; + }, + setConnected: async function (value) { + connected = value; + if (connected && connectedHandler) { + await connectedHandler(); + } + }, + forwardClipboardEvent: function (payload) { + return respond("CLIPBOARD_EVENT", payload); + }, + listHistory: async function () { + if (!connected) { + return { + ok: false, + error: { code: "HOST_UNAVAILABLE", message: "offline", recoverable: true }, + }; + } + return { + ok: true, + payload: { + history: config.nativeHistory || [], + }, + }; + }, + deleteHistory: function (payload) { + return respond("HISTORY_DELETE", payload); + }, + clearHistory: function (payload) { + return respond("HISTORY_CLEAR", payload); + }, + pinHistory: function (payload) { + return respond("HISTORY_PIN", payload); + }, + }; +} + +function createFakeIndexedDB() { + const databases = new Map(); + + function clone(value) { + return value === undefined ? value : structuredClone(value); + } + + function createRequest(executor, transaction) { + const request = {}; + if (transaction) { + transaction.pending += 1; + } + setTimeout(function () { + try { + request.result = executor(); + if (request.onsuccess) { + request.onsuccess({ target: request }); + } + } catch (error) { + request.error = error; + if (request.onerror) { + request.onerror({ target: request }); + } + if (transaction && transaction.onerror) { + transaction.onerror({ target: transaction }); + } + } finally { + if (transaction) { + transaction.pending -= 1; + transaction.scheduleComplete(); + } + } + }, 0); + return request; + } + + function createTransaction(state) { + let completeHandler = null; + const transaction = { + pending: 0, + error: null, + onerror: null, + onabort: null, + objectStore: function (name) { + if (!state.stores.has(name)) { + state.stores.set(name, new Map()); + } + const records = state.stores.get(name); + return { + getAll: function () { + return createRequest(function () { + return Array.from(records.values()).map(clone); + }, transaction); + }, + get: function (key) { + return createRequest(function () { + return clone(records.get(key)); + }, transaction); + }, + put: function (record) { + return createRequest(function () { + const key = record.id || record.key; + records.set(key, clone(record)); + return key; + }, transaction); + }, + clear: function () { + return createRequest(function () { + records.clear(); + return undefined; + }, transaction); + }, + delete: function (key) { + return createRequest(function () { + records.delete(key); + return undefined; + }, transaction); + }, + }; + }, + scheduleComplete: function () { + if (transaction.pending !== 0 || !completeHandler) { + return; + } + setTimeout(function () { + if (transaction.pending === 0 && completeHandler) { + completeHandler({ target: transaction }); + } + }, 0); + }, + }; + Object.defineProperty(transaction, "oncomplete", { + get: function () { + return completeHandler; + }, + set: function (handler) { + completeHandler = handler; + transaction.scheduleComplete(); + }, + }); + return transaction; + } + + function createDatabase(state) { + return { + objectStoreNames: { + contains: function (name) { + return state.stores.has(name); + }, + }, + createObjectStore: function (name) { + if (!state.stores.has(name)) { + state.stores.set(name, new Map()); + } + }, + transaction: function () { + return createTransaction(state); + }, + }; + } + + return { + open: function (name) { + const request = {}; + setTimeout(function () { + let state = databases.get(name); + const needsUpgrade = !state; + if (!state) { + state = { stores: new Map() }; + databases.set(name, state); + } + request.result = createDatabase(state); + if (needsUpgrade && request.onupgradeneeded) { + request.onupgradeneeded({ target: request }); + } + if (request.onsuccess) { + request.onsuccess({ target: request }); + } + }, 0); + return request; + }, + }; +} + +test("local history add still forwards native event when local history is disabled", async function () { + const storage = createStorage(); + const nativeBridge = createNativeBridge(); + const controller = createLocalHistoryController(storage.utils, nativeBridge); + + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 0, + entry: { text: "local", createdAt: 1 }, + nativeEvent: { text: "native", source: "web-extension" }, + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual(response.payload.local, { + ok: true, + stored: false, + reason: "local-history-disabled", + }); + assert.equal(response.payload.native.ok, true); + const forwarded = structuredClone(nativeBridge.calls); + assert.equal(typeof forwarded[0].payload.operationAt, "number"); + delete forwarded[0].payload.operationAt; + assert.deepEqual(forwarded, [{ + type: "CLIPBOARD_EVENT", + payload: { text: "native", source: "web-extension", createdAt: 1 }, + }]); + assert.deepEqual(storage.read().copyHistorySyncOutbox, []); +}); + +test("local history add serializes storage updates", async function () { + let copyHistory = []; + let outbox = []; + const utils = Object.assign({}, core, { + safeStorageGet: async function (area, defaults) { + await new Promise(function (resolve) { setTimeout(resolve, 5); }); + return Object.assign({}, defaults, { + copyHistory: copyHistory.slice(), + copyHistorySyncOutbox: structuredClone(outbox), + }); + }, + safeStorageSet: async function (area, value) { + assert.equal(area, "local"); + await new Promise(function (resolve) { setTimeout(resolve, 5); }); + if (value.copyHistory) { + copyHistory = value.copyHistory.slice(); + } + if (value.copyHistorySyncOutbox) { + outbox = structuredClone(value.copyHistorySyncOutbox); + } + return true; + }, + }); + const nativeBridge = createNativeBridge(); + const controller = createLocalHistoryController(utils, nativeBridge); + + const first = sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 10, + entry: { text: "first", createdAt: 1 }, + nativeEvent: { text: "first" }, + }, + }); + const second = sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 10, + entry: { text: "second", createdAt: 2 }, + nativeEvent: { text: "second" }, + }, + }); + + const responses = await Promise.all([first, second]); + + assert.equal(responses[0].ok, true); + assert.equal(responses[1].ok, true); + assert.deepEqual(copyHistory.map(function (entry) { return entry.text; }), ["second", "first"]); + assert.deepEqual(nativeBridge.calls.filter(function (call) { + return call.type === "CLIPBOARD_EVENT"; + }).map(function (call) { + return call.payload.text; + }).sort(), ["first", "second"]); +}); + +test("offline add remains queued and flushes after reconnect", async function () { + const storage = createStorage(); + const nativeBridge = createNativeBridge({ connected: false }); + const controller = createLocalHistoryController(storage.utils, nativeBridge); + + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 10, + entry: { id: "local-a", text: "offline item", createdAt: 10 }, + nativeEvent: { text: "offline item", createdAt: 10 }, + }, + }); + + assert.equal(response.ok, true); + assert.equal(response.payload.native.ok, false); + assert.equal(storage.read().copyHistorySyncOutbox.length, 1); + + await nativeBridge.setConnected(true); + + assert.deepEqual(nativeBridge.calls.map(function (call) { return call.type; }), [ + "CLIPBOARD_EVENT", + "CLIPBOARD_EVENT", + ]); + assert.deepEqual(storage.read().copyHistorySyncOutbox, []); +}); + +test("offline pin and delete operations flush in FIFO order with text fallback", async function () { + const storage = createStorage({ + copyHistory: [ + { id: "local-a", text: "keep pinned", createdAt: 2 }, + { id: "local-b", text: "remove later", createdAt: 1 }, + ], + }); + const nativeBridge = createNativeBridge({ connected: false }); + const controller = createLocalHistoryController(storage.utils, nativeBridge); + + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_PIN", + payload: { id: "local-a", pinned: true }, + }); + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_DELETE", + payload: { id: "local-b" }, + }); + + const offlineState = storage.read(); + assert.equal(offlineState.copyHistory[0].pinned, true); + assert.deepEqual(offlineState.copyHistory.map(function (entry) { return entry.text; }), ["keep pinned"]); + assert.deepEqual(offlineState.copyHistorySyncOutbox.map(function (op) { return op.type; }), ["pin", "delete"]); + + await nativeBridge.setConnected(true); + + const syncedCalls = nativeBridge.calls.filter(function (call) { + return call.type !== "CLIPBOARD_EVENT"; + }).slice(-2); + assert.deepEqual(syncedCalls, [ + { + type: "HISTORY_PIN", + payload: { id: "local-a", text: "keep pinned", pinned: true, operationAt: syncedCalls[0].payload.operationAt }, + }, + { + type: "HISTORY_DELETE", + payload: { id: "local-b", text: "remove later", operationAt: syncedCalls[1].payload.operationAt }, + }, + ]); + assert.deepEqual(storage.read().copyHistorySyncOutbox, []); +}); + +test("offline pin preserves configured local history limit above default", async function () { + const entries = Array.from({ length: 30 }, function (_, index) { + return { + id: "item-" + index, + text: "Item " + index, + createdAt: 100 - index, + }; + }); + const storage = createStorage({ + copyHistory: entries, + sync: { copyHistoryLimit: 30 }, + }); + const nativeBridge = createNativeBridge({ connected: false }); + const controller = createLocalHistoryController(storage.utils, nativeBridge); + + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_PIN", + payload: { id: "item-29", pinned: true }, + }); + + assert.equal(response.ok, true); + assert.equal(storage.read().copyHistory.length, 30); + assert.equal(storage.read().copyHistory.find(function (entry) { + return entry.id === "item-29"; + }).pinned, true); +}); + +test("offline pin does not trim local history when sync settings read fails", async function () { + const entries = Array.from({ length: 30 }, function (_, index) { + return { + id: "item-" + index, + text: "Item " + index, + createdAt: 100 - index, + }; + }); + const storage = createStorage({ + copyHistory: entries, + syncThrows: true, + }); + const nativeBridge = createNativeBridge({ connected: false }); + const controller = createLocalHistoryController(storage.utils, nativeBridge); + + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_PIN", + payload: { id: "item-29", pinned: true }, + }); + + assert.equal(response.ok, true); + assert.equal(storage.read().copyHistory.length, 30); + assert.equal(storage.read().copyHistory.find(function (entry) { + return entry.id === "item-29"; + }).pinned, true); +}); + +test("newer pin and upsert operations remain ordered after offline mutations", async function () { + const storage = createStorage({ + copyHistory: [{ id: "local-a", text: "ordered item", createdAt: 1 }], + }); + const nativeBridge = createNativeBridge({ connected: false }); + const controller = createLocalHistoryController(storage.utils, nativeBridge); + + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_PIN", + payload: { id: "local-a", pinned: true }, + }); + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_PIN", + payload: { id: "local-a", pinned: false }, + }); + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_DELETE", + payload: { id: "local-a" }, + }); + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 10, + entry: { id: "local-a-new", text: "ordered item", createdAt: 5 }, + nativeEvent: { text: "ordered item", createdAt: 5 }, + }, + }); + + await nativeBridge.setConnected(true); + + const successfulCalls = nativeBridge.calls.slice(-4); + assert.deepEqual(successfulCalls.map(function (call) { return call.type; }), [ + "HISTORY_PIN", + "HISTORY_PIN", + "HISTORY_DELETE", + "CLIPBOARD_EVENT", + ]); + assert.equal(successfulCalls[0].payload.pinned, true); + assert.equal(successfulCalls[1].payload.pinned, false); + assert.deepEqual(storage.read().copyHistorySyncOutbox, []); +}); + +test("clear drops older pending operations but preserves operations created afterward", async function () { + const storage = createStorage(); + const nativeBridge = createNativeBridge({ connected: false }); + const controller = createLocalHistoryController(storage.utils, nativeBridge); + + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 10, + entry: { id: "old", text: "old item", createdAt: 1 }, + nativeEvent: { text: "old item", createdAt: 1 }, + }, + }); + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_CLEAR", + }); + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 10, + entry: { id: "new", text: "new item", createdAt: 3 }, + nativeEvent: { text: "new item", createdAt: 3 }, + }, + }); + + assert.deepEqual(storage.read().copyHistorySyncOutbox.map(function (op) { return op.type; }), ["clear", "upsert"]); + + await nativeBridge.setConnected(true); + + const successfulCalls = nativeBridge.calls.slice(-2); + assert.deepEqual(successfulCalls.map(function (call) { return call.type; }), ["HISTORY_CLEAR", "CLIPBOARD_EVENT"]); + assert.equal(successfulCalls[1].payload.text, "new item"); + assert.deepEqual(storage.read().copyHistorySyncOutbox, []); +}); + +test("history list uses Companion while connected and local fallback while offline", async function () { + const localEntry = { id: "local", text: "local item", createdAt: 1 }; + const nativeEntry = { id: "native", text: "native item", createdAt: 2 }; + const storage = createStorage({ copyHistory: [localEntry] }); + const nativeBridge = createNativeBridge({ nativeHistory: [nativeEntry] }); + const controller = createLocalHistoryController(storage.utils, nativeBridge); + + const connected = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_LIST", + payload: { limit: 10 }, + }); + assert.equal(connected.ok, true); + assert.equal(connected.payload.mode, "companion"); + assert.deepEqual(connected.payload.history, [nativeEntry]); + + await nativeBridge.setConnected(false); + const offline = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_LIST", + payload: { limit: 10 }, + }); + assert.equal(offline.ok, true); + assert.equal(offline.payload.mode, "local"); + assert.equal(offline.payload.history[0].text, "local item"); +}); + +test("history trimming shares the storage queue and preserves pending operations", async function () { + const pending = { + id: "pending-1", + type: "delete", + createdAt: 1, + payload: { id: "a", text: "A" }, + }; + const storage = createStorage({ + copyHistory: [ + { id: "a", text: "A", createdAt: 3 }, + { id: "b", text: "B", createdAt: 2 }, + { id: "c", text: "C", createdAt: 1 }, + ], + copyHistorySyncOutbox: [pending], + }); + const controller = createLocalHistoryController(storage.utils, createNativeBridge({ connected: false })); + + await controller.trimHistory(1); + + assert.deepEqual(storage.read().copyHistory.map(function (entry) { return entry.text; }), ["A"]); + assert.deepEqual(storage.read().copyHistorySyncOutbox, [pending]); +}); + +test("IndexedDB outbox migrates legacy storage and keeps new operations out of chrome storage", async function () { + const legacy = { + id: "legacy-delete", + type: "delete", + createdAt: 10, + payload: { id: "a", text: "A" }, + }; + const storage = createStorage({ copyHistorySyncOutbox: [legacy] }); + storage.utils.indexedDB = createFakeIndexedDB(); + const outboxStore = createIndexedDBOutboxStore(storage.utils); + + assert.deepEqual((await outboxStore.list()).map(function (op) { return op.id; }), ["legacy-delete"]); + assert.deepEqual(storage.read().copyHistorySyncOutbox, []); + + await outboxStore.append({ + id: "new-upsert", + type: "upsert", + createdAt: 20, + payload: { event: { text: "B" } }, + }); + + assert.deepEqual(storage.read().copyHistorySyncOutbox, []); + assert.deepEqual((await outboxStore.list()).map(function (op) { return op.id; }), ["legacy-delete", "new-upsert"]); + + await outboxStore.remove("legacy-delete"); + assert.deepEqual((await outboxStore.list()).map(function (op) { return op.id; }), ["new-upsert"]); +}); + +test("IndexedDB outbox stores bounded large pending payloads without using chrome storage outbox", async function () { + const storage = createStorage(); + storage.utils.indexedDB = createFakeIndexedDB(); + const outboxStore = createIndexedDBOutboxStore(storage.utils); + const nativeBridge = createNativeBridge({ connected: false }); + nativeBridge.normalizeClipboardEventPayload = function (payload) { + const text = String(payload.text || ""); + return { + text: text.slice(0, 512 * 1024), + truncated: text.length > 512 * 1024, + createdAt: Number(payload.createdAt || 1), + source: "web-extension", + }; + }; + const controller = createLocalHistoryController(storage.utils, nativeBridge, { outboxStore }); + + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 0, + entry: { id: "large", text: "x".repeat(512 * 1024 + 128), createdAt: 50 }, + nativeEvent: { text: "x".repeat(512 * 1024 + 128), createdAt: 50 }, + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual(storage.read().copyHistorySyncOutbox, []); + const pending = await outboxStore.list(); + assert.equal(pending.length, 1); + assert.equal(pending[0].payload.event.text.length, 512 * 1024); + assert.equal(pending[0].payload.event.truncated, true); +}); diff --git a/tests/native-messaging.test.js b/tests/native-messaging.test.js new file mode 100644 index 0000000..ad94c5a --- /dev/null +++ b/tests/native-messaging.test.js @@ -0,0 +1,219 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + createNativeMessagingBridge, + truncateUtf8Bytes, +} = require("../src/background/native-messaging.js"); + +function createUtils() { + return { + isExtensionContextValid: function () { return true; }, + getTextSnippet: function (text) { return String(text || "").slice(0, 40); }, + detectSmartFormat: function (text) { + return String(text || "").trim().startsWith("{") ? "json" : "plain"; + }, + normalizeDomain: function (value) { return String(value || "").toLowerCase(); }, + getHostnameFromUrl: function (value) { + try { + return new URL(value).hostname; + } catch { + return ""; + } + }, + }; +} + +function createChromeWithNativeHost(onPostMessage) { + let hostMessageListener = null; + let disconnectListener = null; + const port = { + onMessage: { + addListener: function (listener) { + hostMessageListener = listener; + }, + }, + onDisconnect: { + addListener: function (listener) { + disconnectListener = listener; + }, + }, + postMessage: function (message) { + onPostMessage(message); + setTimeout(function () { + hostMessageListener({ + id: message.id, + ok: true, + payload: { + type: message.type, + opened: message.type === "OPEN_APP", + }, + }); + }, 0); + }, + disconnect: function () { + if (disconnectListener) { + disconnectListener(); + } + }, + }; + + return { + runtime: { + id: "test-extension", + lastError: null, + getManifest: function () { + return { version: "9.9.9" }; + }, + connectNative: function () { + return port; + }, + }, + }; +} + +function createChromeWithSilentNativeHost(onPostMessage) { + let disconnectListener = null; + let disconnected = false; + const port = { + onMessage: { + addListener: function () {}, + }, + onDisconnect: { + addListener: function (listener) { + disconnectListener = listener; + }, + }, + postMessage: function (message) { + onPostMessage(message); + }, + disconnect: function () { + disconnected = true; + if (disconnectListener) { + disconnectListener(); + } + }, + }; + + return { + get disconnected() { + return disconnected; + }, + chrome: { + runtime: { + id: "test-extension", + lastError: null, + getManifest: function () { + return { version: "9.9.9" }; + }, + connectNative: function () { + return port; + }, + }, + }, + }; +} + +function sendRuntimeMessage(bridge, message) { + return new Promise(function (resolve) { + bridge.handleRuntimeMessage(message, {}, resolve); + }); +} + +test("native bridge normalizes and bounds clipboard events before outbox persistence", function () { + const bridge = createNativeMessagingBridge(createUtils()); + const payload = bridge.normalizeClipboardEventPayload({ + text: "x".repeat(512 * 1024 + 100), + url: "https://Example.com/path", + }); + + assert.equal(payload.text.length, 512 * 1024); + assert.equal(payload.truncated, true); + assert.equal(payload.hostname, "example.com"); + assert.equal(typeof payload.operationAt, "number"); +}); + +test("native bridge bounds clipboard text by UTF-8 bytes", function () { + const bounded = truncateUtf8Bytes("😀".repeat(200000), 512 * 1024); + const size = new TextEncoder().encode(bounded.text).length; + + assert.equal(bounded.truncated, true); + assert.ok(size <= 512 * 1024); + assert.equal(bounded.text.codePointAt(bounded.text.length - 2), 0x1F600); +}); + +test("native bridge routes open app messages to the companion host", async function () { + const originalChrome = global.chrome; + const posted = []; + global.chrome = createChromeWithNativeHost(function (message) { + posted.push(message); + }); + + const bridge = createNativeMessagingBridge(createUtils()); + const response = await sendRuntimeMessage(bridge, { type: "COPY_TEXT_NATIVE_OPEN_APP" }); + + assert.equal(response.ok, true); + assert.equal(response.payload.opened, true); + assert.equal(posted[0].type, "OPEN_APP"); + assert.equal(posted[0].version, 1); + + global.chrome = originalChrome; +}); + +test("native bridge forwards history and settings messages", async function () { + const originalChrome = global.chrome; + const posted = []; + global.chrome = createChromeWithNativeHost(function (message) { + posted.push(message); + }); + + const bridge = createNativeMessagingBridge(createUtils()); + await sendRuntimeMessage(bridge, { type: "COPY_TEXT_NATIVE_HISTORY_LIST", payload: { query: "json", limit: 5 } }); + await sendRuntimeMessage(bridge, { type: "COPY_TEXT_NATIVE_HISTORY_DELETE", payload: { id: "a", text: "Alpha", operationAt: 10 } }); + await sendRuntimeMessage(bridge, { type: "COPY_TEXT_NATIVE_HISTORY_CLEAR" }); + await sendRuntimeMessage(bridge, { type: "COPY_TEXT_NATIVE_HISTORY_PIN", payload: { id: "a", text: "Alpha", pinned: true, operationAt: 11 } }); + await sendRuntimeMessage(bridge, { type: "COPY_TEXT_NATIVE_FORMAT_PREVIEW", payload: { text: "{\"ok\":true}", action: "prettyJson" } }); + await sendRuntimeMessage(bridge, { type: "COPY_TEXT_NATIVE_SETTINGS_GET" }); + await sendRuntimeMessage(bridge, { type: "COPY_TEXT_NATIVE_SETTINGS_UPDATE", payload: { maxItems: 20 } }); + + assert.deepEqual(posted.map(function (message) { return message.type; }), [ + "HISTORY_LIST", + "HISTORY_DELETE", + "HISTORY_CLEAR", + "HISTORY_PIN", + "FORMAT_PREVIEW", + "SETTINGS_GET", + "SETTINGS_UPDATE", + ]); + assert.deepEqual(posted[0].payload, { query: "json", limit: 5 }); + assert.deepEqual(posted[1].payload, { id: "a", text: "Alpha", operationAt: 10 }); + assert.equal(typeof posted[2].payload.operationAt, "number"); + assert.deepEqual(posted[3].payload, { id: "a", text: "Alpha", pinned: true, operationAt: 11 }); + assert.deepEqual(posted[4].payload, { text: "{\"ok\":true}", action: "prettyJson" }); + assert.deepEqual(posted[6].payload, { maxItems: 20 }); + + global.chrome = originalChrome; +}); + +test("native bridge falls back to local mode after host timeout", async function () { + const originalChrome = global.chrome; + const posted = []; + const host = createChromeWithSilentNativeHost(function (message) { + posted.push(message); + }); + global.chrome = host.chrome; + + const bridge = createNativeMessagingBridge(createUtils()); + const response = await sendRuntimeMessage(bridge, { type: "COPY_TEXT_NATIVE_PING" }); + + assert.equal(response.ok, false); + assert.equal(response.error.code, "HOST_TIMEOUT"); + assert.equal(response.error.recoverable, true); + assert.equal(host.disconnected, true); + assert.equal(posted.length, 1); + + const status = bridge.getStatus(); + assert.equal(status.connected, false); + assert.match(status.lastError, /did not respond/i); + + global.chrome = originalChrome; +}); diff --git a/tests/release-utils.test.js b/tests/release-utils.test.js index 6fa8dd1..0ae4db0 100644 --- a/tests/release-utils.test.js +++ b/tests/release-utils.test.js @@ -5,6 +5,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const releaseUtils = require("../scripts/lib/release-utils.cjs"); +const installNativeHost = require("../scripts/install-native-host-windows.cjs"); test("validateManifestData rejects Firefox-only fields in the Chrome manifest", function () { const errors = releaseUtils.validateManifestData({ @@ -72,3 +73,18 @@ test("createDeterministicZipFromDirectory produces stable bytes", function () { fs.rmSync(tempRoot, { recursive: true, force: true }); }); + +test("native host installer validates unpacked Chrome extension ids", function () { + assert.doesNotThrow(function () { + installNativeHost.validateExtensionId("abcdefghijklmnopabcdefghijklmnop"); + }); + assert.throws(function () { + installNativeHost.validateExtensionId("ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP"); + }, /32-character Chrome extension ID/); + assert.throws(function () { + installNativeHost.validateExtensionId("abcdefghijklmnopabcdefghijklmnq"); + }, /32-character Chrome extension ID/); + assert.throws(function () { + installNativeHost.validateExtensionId("short"); + }, /32-character Chrome extension ID/); +}); diff --git a/tests/shared.test.js b/tests/shared.test.js index 0512835..31984d8 100644 --- a/tests/shared.test.js +++ b/tests/shared.test.js @@ -93,6 +93,98 @@ test("pushHistoryEntry deduplicates by text and enforces the limit", function () assert.equal(history[1].text, "Keep"); }); +test("pushHistoryEntry treats zero limit as disabled", function () { + const history = utils.pushHistoryEntry([ + { id: "1", text: "Old", snippet: "Old", createdAt: 1, source: "click", mode: "copy" } + ], { + text: "New", + snippet: "New", + createdAt: 2, + source: "click", + mode: "copy" + }, 0); + + assert.deepEqual(history, []); +}); + +test("pushHistoryEntry keeps pinned items outside the unpinned limit", function () { + const history = utils.pushHistoryEntry([ + { id: "pinned", text: "Pinned", snippet: "Pinned", createdAt: 1, source: "click", mode: "copy", pinned: true }, + { id: "old", text: "Old", snippet: "Old", createdAt: 2, source: "click", mode: "copy" } + ], { + text: "New", + snippet: "New", + createdAt: 3, + source: "click", + mode: "copy" + }, 1); + + assert.deepEqual(history.map(function (entry) { return entry.text; }), ["New", "Pinned"]); +}); + +test("pushHistoryEntry annotates smart format metadata", function () { + const history = utils.pushHistoryEntry([], { + text: "{\"name\":\"Codex\"}", + snippet: "json", + createdAt: 1, + source: "click", + mode: "copy", + url: "https://example.com" + }, 10); + + assert.equal(history[0].format, "json"); +}); + +test("pushHistoryEntry preserves copied text whitespace", function () { + const text = " {\"name\":\"Codex\"}\n"; + const history = utils.pushHistoryEntry([], { + text, + snippet: "json", + createdAt: 1, + source: "native", + mode: "copy", + }, 10); + + assert.equal(history[0].text, text); + assert.equal(history[0].format, "json"); +}); + +test("detectSmartFormat classifies developer clipboard formats", function () { + const jwtHeader = Buffer.from("{\"alg\":\"HS256\",\"typ\":\"JWT\"}", "utf8").toString("base64url"); + const jwtPayload = Buffer.from("{\"exp\":1790000000}", "utf8").toString("base64url"); + + assert.equal(utils.detectSmartFormat("{\"ok\":true}"), "json"); + assert.equal(utils.detectSmartFormat("select * from users where id = 1"), "sql"); + assert.equal(utils.detectSmartFormat(jwtHeader + "." + jwtPayload + ".signature"), "jwt"); + assert.equal(utils.detectSmartFormat("1717886400"), "timestamp"); + assert.equal(utils.detectSmartFormat("2026-06-08 22:26:02"), "date"); + assert.equal(utils.detectSmartFormat("SGVsbG8gd29ybGQ="), "base64"); + assert.equal(utils.detectSmartFormat("abcdefghijkl"), "plain"); + assert.equal(utils.detectSmartFormat("hello world"), "plain"); +}); + +test("applySmartAction formats and transforms copied text", function () { + assert.equal(utils.applySmartAction("{\"b\":2}", "prettyJson"), "{\n \"b\": 2\n}"); + assert.equal(utils.applySmartAction("{\n \"b\": 2\n}", "minifyJson"), "{\"b\":2}"); + assert.equal(utils.applySmartAction("select * from users where id=1", "formatSql"), "SELECT *\nFROM users\nWHERE id=1"); + assert.match(utils.applySmartAction("select 'from x' as value -- keep spacing\nfrom users", "formatSql"), /'from x'/); + assert.match(utils.applySmartAction("select 'from x' as value -- keep spacing\nfrom users", "formatSql"), /-- keep spacing/); + assert.equal(utils.applySmartAction("Hello world", "snake"), "hello_world"); + assert.equal(utils.applySmartAction("Hello world", "base64Encode"), "SGVsbG8gd29ybGQ="); + assert.equal(utils.applySmartAction("SGVsbG8gd29ybGQ=", "base64Decode"), "Hello world"); + assert.equal(utils.applySmartAction("not base64 text", "base64Decode"), "not base64 text"); +}); + +test("applySmartAction decodes JWT header and payload", function () { + const header = Buffer.from("{\"alg\":\"HS256\",\"typ\":\"JWT\"}", "utf8").toString("base64url"); + const payload = Buffer.from("{\"sub\":\"123\"}", "utf8").toString("base64url"); + const decoded = utils.applySmartAction(header + "." + payload + ".signature", "decodeJwt"); + + assert.match(decoded, /HEADER/); + assert.match(decoded, /PAYLOAD/); + assert.match(decoded, /"sub": "123"/); +}); + test("getTextSnippet collapses whitespace and truncates long text", function () { assert.equal(utils.getTextSnippet(" A long text "), "A long text"); assert.equal(utils.getTextSnippet("1234567890", 8), "12345..."); From ca08b886ec22b73b628fb74a3bf4c27f8b6214fc Mon Sep 17 00:00:00 2001 From: VET Date: Thu, 11 Jun 2026 16:41:59 +0700 Subject: [PATCH 08/12] Unify companion launch around the extension shortcut Chrome already owns Alt+Shift+C as the extension command, so the shortcut flow now waits for a content-script copy acknowledgement and then opens the native Companion through the existing OPEN_APP bridge. The Companion still stores the preferred hotkey, but failed Windows global registration no longer rolls settings back; the UI reports that the saved hotkey is not active when Windows rejects the duplicate. The same pass keeps the installed Companion aligned with release expectations by allowing 9999 history items, syncing the Wails app icon from the extension icon during native builds, and documenting the manual icon/hotkey smoke points. Constraint: Windows RegisterHotKey rejects duplicate process-global shortcuts when Chrome owns Alt+Shift+C Constraint: Wails v2.12 only regenerates build/windows/icon.ico when the stale file is missing Rejected: Register Alt+Shift+C in both Chrome and Companion | duplicate global hotkeys fail and make settings look unstable Rejected: Leave Wails build/appicon.png as an ignored manual artifact | fresh builds could silently return to the default Wails icon Confidence: high Scope-risk: moderate Directive: Keep Alt+Shift+C owned by the extension command path; Companion hotkey activation remains best-effort when Windows reports a conflict Tested: npm run validate Tested: npm run test:e2e Tested: npm run native:build Tested: npm run native:test-host Tested: npm run test:e2e:native Tested: Playwright unpacked popup Open app smoke against extension pahmaphhgccgealefimmgjcfmobgofpp Not-tested: npm run native:test-tray in this turn --- companion/README.md | 8 ++ companion/app.go | 6 +- companion/frontend/dist/app.css | 11 ++ companion/frontend/dist/app.js | 97 +++++++++++++ companion/frontend/dist/index.html | 7 +- companion/hotkey_windows_test.go | 13 ++ companion/internal/companion/service_test.go | 21 +++ companion/internal/companion/store.go | 4 +- companion/internal/companion/types.go | 1 + docs/release-companion-checklist.md | 1 + scripts/build-companion.cjs | 33 ++++- src/background/index.js | 2 + src/background/shortcut.js | 42 ++++-- src/content/bootstrap.js | 20 ++- src/content/clipboard.js | 7 +- tests/release-utils.test.js | 36 +++++ tests/shortcut.test.js | 140 +++++++++++++++++++ 17 files changed, 424 insertions(+), 25 deletions(-) create mode 100644 tests/shortcut.test.js diff --git a/companion/README.md b/companion/README.md index 6cada51..d967c27 100644 --- a/companion/README.md +++ b/companion/README.md @@ -73,6 +73,11 @@ From the repository root: npm run native:build ``` +The native build syncs `companion/assets/appicon.png` into Wails' +generated `build/appicon.png` before packaging. Keep that companion icon +byte-identical to the extension `icon.png` so the window, executable, and tray +all use the same visual identity. + Register the Chrome native messaging host for the current user: ```powershell @@ -100,6 +105,9 @@ the current user's Google Chrome and Chromium native-messaging registry keys. - Wails v2 menu accelerators are wired for show/hide/quit. Windows global hotkey registration is enabled from the local hotkey setting at startup. + If Windows reports that a preferred hotkey is already registered by another + app, the preference is still saved but the Companion reports that the global + hotkey is not active until that shortcut is released or changed. - Windows tray support is implemented with direct Win32 `Shell_NotifyIconW` calls because Wails v2.12 does not expose a stable public tray option on `options.App`. The tray icon reuses the embedded companion app icon and diff --git a/companion/app.go b/companion/app.go index 0ba6af9..f83fb35 100644 --- a/companion/app.go +++ b/companion/app.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "log" "sync" "time" @@ -125,16 +126,15 @@ func (app *App) UpdateSettings(patch companion.SettingsPatch) (companion.Setting if err != nil { return companion.Settings{}, err } - appliedAutoStart := false if patch.AutoStart != nil { if err := configureAutoStart(settings.AutoStart); err != nil { return companion.Settings{}, errors.Join(err, app.restoreSettings(previous, true, false)) } - appliedAutoStart = true } if patch.Hotkey != nil && app.hotkey != nil { if err := app.hotkey.Update(settings.Hotkey); err != nil { - return companion.Settings{}, errors.Join(err, app.restoreSettings(previous, appliedAutoStart, true)) + log.Printf("register global hotkey: %v", err) + return settings, fmt.Errorf("settings saved, but global hotkey is not active: %w", err) } } return settings, nil diff --git a/companion/frontend/dist/app.css b/companion/frontend/dist/app.css index 2e1c2a3..c163961 100644 --- a/companion/frontend/dist/app.css +++ b/companion/frontend/dist/app.css @@ -177,6 +177,17 @@ h1 { gap: 7px; } +.hotkey-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.hotkey-row button { + white-space: nowrap; +} + .settings { gap: 12px; } diff --git a/companion/frontend/dist/app.js b/companion/frontend/dist/app.js index f547208..fa4644c 100644 --- a/companion/frontend/dist/app.js +++ b/companion/frontend/dist/app.js @@ -11,6 +11,7 @@ historyEnabled: document.getElementById("history_enabled"), maxItems: document.getElementById("max_items"), hotkey: document.getElementById("hotkey"), + hotkeyCopyShortcut: document.getElementById("hotkey_copy_shortcut"), autoStart: document.getElementById("auto_start"), refresh: document.getElementById("refresh_button"), clear: document.getElementById("clear_button"), @@ -54,6 +55,11 @@ elements.historyEnabled.addEventListener("change", saveSettings); elements.maxItems.addEventListener("change", saveSettings); elements.hotkey.addEventListener("change", saveSettings); + elements.hotkey.addEventListener("keydown", captureHotkey); + elements.hotkeyCopyShortcut.addEventListener("click", function () { + elements.hotkey.value = "Alt+Shift+C"; + saveSettings(); + }); elements.autoStart.addEventListener("change", saveSettings); } @@ -75,6 +81,7 @@ async function saveSettings() { try { + elements.hotkey.value = normalizeHotkeyInput(elements.hotkey.value); const settings = await call("UpdateSettings", { historyEnabled: elements.historyEnabled.checked, maxItems: Number(elements.maxItems.value) || 500, @@ -90,6 +97,96 @@ } } + function captureHotkey(event) { + if (event.key === "Tab") { + return; + } + if (event.key === "Escape") { + event.preventDefault(); + elements.hotkey.value = state.settings && state.settings.hotkey + ? state.settings.hotkey + : "Ctrl+Shift+Space"; + return; + } + + const key = normalizeHotkeyKey(event.key); + if (!key) { + if (isModifierKey(event.key)) { + event.preventDefault(); + } + return; + } + + const parts = []; + if (event.ctrlKey) { + parts.push("Ctrl"); + } + if (event.altKey) { + parts.push("Alt"); + } + if (event.shiftKey) { + parts.push("Shift"); + } + if (!parts.length) { + return; + } + + event.preventDefault(); + parts.push(key); + elements.hotkey.value = parts.join("+"); + saveSettings(); + } + + function normalizeHotkeyKey(value) { + const key = String(value || ""); + if (isModifierKey(key)) { + return ""; + } + if (key === " " || key === "Spacebar" || key === "Space") { + return "Space"; + } + if (/^[a-z]$/i.test(key)) { + return key.toUpperCase(); + } + if (/^[0-9]$/.test(key)) { + return key; + } + return ""; + } + + function isModifierKey(value) { + return ["Alt", "AltGraph", "Control", "Meta", "Shift"].includes(String(value || "")); + } + + function normalizeHotkeyInput(value) { + return String(value || "") + .split("+") + .map(function (part) { + const token = part.trim(); + if (/^control$/i.test(token)) { + return "Ctrl"; + } + if (/^ctrl$/i.test(token)) { + return "Ctrl"; + } + if (/^alt$/i.test(token) || /^option$/i.test(token)) { + return "Alt"; + } + if (/^shift$/i.test(token)) { + return "Shift"; + } + if (/^space$/i.test(token)) { + return "Space"; + } + if (/^[a-z]$/i.test(token)) { + return token.toUpperCase(); + } + return token; + }) + .filter(Boolean) + .join("+"); + } + async function refreshHistory() { try { const query = elements.search.value || ""; diff --git a/companion/frontend/dist/index.html b/companion/frontend/dist/index.html index 3c43330..557b018 100644 --- a/companion/frontend/dist/index.html +++ b/companion/frontend/dist/index.html @@ -29,11 +29,14 @@

Copy Text Companion