diff --git a/.github/workflows/chrome-ci.yml b/.github/workflows/chrome-ci.yml new file mode 100644 index 0000000..5b145e3 --- /dev/null +++ b/.github/workflows/chrome-ci.yml @@ -0,0 +1,55 @@ +name: Chrome CI + +on: + push: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - 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 + + e2e: + runs-on: ubuntu-latest + needs: validate + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + 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 7d861d5..74a1d90 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,19 @@ Network Trash Folder Temporary Items .apdisk -web-ext-artifacts \ No newline at end of file +web-ext-artifacts +dist/ +.omx/ +node_modules/ +test-results/ +# AI Context +.cobridge + +# .traerules +.traerules + +# .cursorrules +.cursorrules + +# GitHub Copilot Instructions +.github/copilot-instructions.md diff --git a/README.md b/README.md index a623d9f..65c704c 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,100 @@ # Copy Text with Alt-Click -Easy and fast copy tool. +Easy and fast copy tool for Chrome MV3. + +## 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 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 +- Developer smart actions for JSON, SQL, JWT, timestamps, case transforms, and Base64 ## 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! +Install the extension, hold the configured modifier key, and click the target text. + +The clicked text is copied immediately without manual selection in the common case. + +## Architecture + +- `src/background/` contains the MV3 startup, registration, shortcut, and local 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 + +- **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 copy feedback remains visible +- **Excluded domains**: one hostname per line, matched against the host and its subdomains +- **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 +- **Keyboard shortcut mode**: lets browser shortcuts trigger copy without requiring a click + +## 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 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 +``` -This alone, the text of the point you click will be copied. +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/_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/docs/browser-manual-test-checklist.md b/docs/browser-manual-test-checklist.md new file mode 100644 index 0000000..91bda9e --- /dev/null +++ b/docs/browser-manual-test-checklist.md @@ -0,0 +1,137 @@ +# Browser Manual Test Checklist + +## Setup + +1. Run `npm run validate`. +2. Load the extension unpacked in Chrome from this repository's `dist\chrome` directory. +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 +``` + +5. 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 + +- 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)` +- [ ] 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 + +- 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 +- [ ] 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 + +- 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 + +## 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 + +- 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`. +- [ ] In the existing Chrome profile, click **Reload** on the unpacked extension without removing it first +- [ ] Existing settings, excluded domains, and local history remain intact after the in-place reload +- [ ] 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 +- [ ] 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 + +- 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 + +## 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/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/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..324804d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,19 +1,16 @@ { "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" - } - }, - "permissions" : [ + "activeTab", + "scripting", "storage", - "clipboardWrite" + "clipboardWrite", + "alarms" ], "host_permissions": [ "http://*/*", @@ -24,19 +21,22 @@ "48" : "icon.png" }, - "content_scripts": [ - { - "matches": [ - "http://*/*", - "https://*/*" - ], - "js": [ - "menu.js" - ], - "run_at" : "document_start" - } - ], + "background": { + "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 deleted file mode 100644 index 2f0b2c8..0000000 --- a/menu.js +++ /dev/null @@ -1,98 +0,0 @@ -(function () { - var metaKey; - - updateMetaKey(); - - chrome.storage.onChanged.addListener(function (changes, areaName) { - if (areaName == "sync") { - updateMetaKey(); - } - }) - - document.addEventListener("click", function (event) { - if ((event.altKey && metaKey == "Alt") - || (event.ctrlKey && metaKey == "Ctrl") - || (event.shiftKey && metaKey == "Shift")) { - copyCommand(event.target); - event.preventDefault(); - } - }, false); - - function updateMetaKey() { - chrome.storage.sync.get({ - metaKey: 'Alt', - }, function (items) { - metaKey = items.metaKey; - }); - }; - - function getText(node) { - if (node.nodeType == Node.TEXT_NODE) { - return getText(node.parentNode); - } - - switch (node.nodeName.toUpperCase()) { - case "INPUT": - case "TEXTAREA": - return node.value; - case "SELECT": - return Array.from(node.selectedOptions).map(o => o.innerText).join("\n") - } - - return node.innerText; - } - - function copyCommand(clickedElement) { - var text = getText(clickedElement); - copy(text); - - 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", - }); - - document.body.appendChild(frame); - setTimeout(() => { - frame.addEventListener("transitionend", (event) => { - frame.remove(); - }); - frame.style.opacity = "0"; - frame.style.transition = "opacity ease-out 0.5s"; - }, 500); - } - - async function copy(text) { - if (navigator.clipboard) { - await navigator.clipboard.writeText(text); - return; - } - - // fallback (for http) - const textArea = document.createElement("textarea"); - textArea.style.cssText = "position: absolute; left: -100%;"; - try { - document.body.appendChild(textArea); - - textArea.value = text; - textArea.select() - - if (!document.execCommand("copy")) { - console.error("Copy failed."); - } - } finally { - document.body.removeChild(textArea); - } - } -})(); diff --git a/options.css b/options.css new file mode 100644 index 0000000..267ea3a --- /dev/null +++ b/options.css @@ -0,0 +1,696 @@ +: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-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; + 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 deleted file mode 100644 index 262163a..0000000 --- a/options.js +++ /dev/null @@ -1,17 +0,0 @@ -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"); 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 new file mode 100644 index 0000000..222d18d --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "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", + "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", + "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..d2da8d1 --- /dev/null +++ b/popup.css @@ -0,0 +1,464 @@ +: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; + } + + .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; + 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 new file mode 100644 index 0000000..2da411f --- /dev/null +++ b/popup.html @@ -0,0 +1,84 @@ + + + + + + Copy text with Alt-Click + + + + +
+
+ + + +
+ +
+
+
+ +
-
+
+ Unsupported +
+ +
+ +
+
+ + +
+ + + + + +
+ + +
+ +
+ + + +
+
+ +
+
+ + +
+
+
+ + +
+ + + + + diff --git a/scripts/build-chrome.cjs b/scripts/build-chrome.cjs new file mode 100644 index 0000000..c2e5063 --- /dev/null +++ b/scripts/build-chrome.cjs @@ -0,0 +1,76 @@ +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"); + +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); + if (errors.length) { + throw new Error(errors.join("\n")); + } + + 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) { + 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: ${verification.actual.length} file(s) -> ${CHROME_DIST_DIR}`); + return { + outputDir: CHROME_DIST_DIR, + files: verification.actual, + }; +} + +if (require.main === module) { + 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-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..eab211b --- /dev/null +++ b/scripts/check-syntax.cjs @@ -0,0 +1,49 @@ +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 sourceFiles = listScriptFiles(path.join(PROJECT_ROOT, "src")); + const scriptFiles = listScriptFiles(path.join(PROJECT_ROOT, "scripts")); + const filesToCheck = sourceFiles.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..151642d --- /dev/null +++ b/scripts/lib/release-utils.cjs @@ -0,0 +1,300 @@ +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", + "icon.png", + "LICENSE.txt", + "manifest.json", + "options.css", + "options.html", + "popup.css", + "popup.html", +]; +const BUNDLED_OUTPUT_FILES = [ + "background.js", + "menu.js", + "options.js", + "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 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); + + 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 = { + BUNDLED_OUTPUT_FILES, + CHROME_DIST_DIR, + DIST_ROOT, + PROJECT_ROOT, + RELEASE_ARCHIVE_PREFIX, + RELEASE_ROOT_ENTRIES, + copyReleaseFiles, + createDeterministicZipFromDirectory, + ensureCleanDir, + getExpectedChromeOutputPaths, + getReleaseEntries, + listFilesRecursive, + readJson, + toPosixPath, + validateManifestData, + verifyChromeBuildOutput, +}; diff --git a/scripts/pack-chrome.cjs b/scripts/pack-chrome.cjs new file mode 100644 index 0000000..c731bd8 --- /dev/null +++ b/scripts/pack-chrome.cjs @@ -0,0 +1,33 @@ +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"); + +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); + + console.log(`Chrome package created: ${archivePath}`); + console.log(`Packed ${packedFiles.length} file(s).`); + return { + archivePath, + files: packedFiles, + }; +} + +if (require.main === module) { + 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..d18ffd0 --- /dev/null +++ b/src/background/index.js @@ -0,0 +1,84 @@ +const utils = require("../shared/core.js"); +const { + HISTORY_CLEANUP_ALARM, + initializeHistoryCleanup, + saveAnalyticsEvent, +} = require("./history-maintenance.js"); +const { + initializeExtension, + reportBackgroundError, +} = require("./registration.js"); +const { createLocalHistoryController } = require("./local-history.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 { + const localHistory = createLocalHistoryController(utils); + localHistory.cleanupLegacyCompanionState().catch(function (error) { + reportBackgroundError(utils, "Removing obsolete companion sync data failed.", error); + }); + + utils.addListenerSafely(chrome.runtime.onInstalled, function () { + initializeExtension(utils, CONTENT_SCRIPT_ID, function (limit) { + return localHistory.trimHistory(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 localHistory.trimHistory(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 localHistory.trimHistory(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); + }); + }); + + utils.addListenerSafely(chrome.runtime.onMessage, function (message, sender, sendResponse) { + return localHistory.handleRuntimeMessage(message, sender, sendResponse); + }); + + 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 localHistory.trimHistory(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/local-history.js b/src/background/local-history.js new file mode 100644 index 0000000..34d4f0a --- /dev/null +++ b/src/background/local-history.js @@ -0,0 +1,285 @@ +const LEGACY_OUTBOX_KEY = "copyHistorySyncOutbox"; +const LEGACY_OUTBOX_DB_NAME = "copyTextHistorySyncOutbox"; + +function createLocalHistoryController(utils) { + let storageQueue = Promise.resolve(); + + function enqueueStorage(work) { + const next = storageQueue.then(work, work); + storageQueue = 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(); + } else if (message.type === "COPY_TEXT_LOCAL_HISTORY_PIN") { + work = handlePin(payload); + } + + if (!work) { + sendResponse({ + ok: false, + error: { + code: "UNKNOWN_MESSAGE", + message: "Unknown local history message.", + recoverable: false, + }, + }); + return false; + } + + 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; + } + + function handleAdd(payload) { + const limit = utils.normalizeCopyHistoryLimit(payload.limit); + if (!limit) { + return Promise.resolve({ + ok: true, + payload: { + local: { ok: true, stored: false, reason: "local-history-disabled" }, + }, + }); + } + + return enqueueStorage(async function () { + const history = trimHistoryToStorageBudget( + utils.pushHistoryEntry(await readHistory(), payload.entry || {}, limit) + ); + const stored = await writeHistory(history); + return mutationResponse(stored, { + stored: !!stored, + count: history.length, + }, "HISTORY_ADD_FAILED", "Failed to store local history."); + }); + } + + function handleList(payload) { + return enqueueStorage(async function () { + let history = await readHistory(); + const query = String(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.limit || 0); + if (limit > 0 && history.length > limit) { + history = history.slice(0, limit); + } + return { + ok: true, + payload: { + history, + mode: "local", + pending: 0, + }, + }; + }); + } + + 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 Promise.resolve(badPayload("history id is required.")); + } + + return enqueueStorage(async function () { + const history = utils.deleteHistoryEntries(await readHistory(), ids); + return mutationResponse( + await writeHistory(history), + {}, + "HISTORY_DELETE_FAILED", + "Failed to delete local history." + ); + }); + } + + function handleClear() { + return enqueueStorage(async function () { + return mutationResponse( + await writeHistory([]), + {}, + "HISTORY_CLEAR_FAILED", + "Failed to clear local history." + ); + }); + } + + function handlePin(payload) { + const id = String(payload.id || ""); + if (!id) { + return Promise.resolve(badPayload("history id is required.")); + } + + return enqueueStorage(async function () { + const current = await readHistory(); + const limit = await readCopyHistoryLimit(payload.limit, current.length); + const history = utils.updateHistoryEntry(current, id, function (entry) { + entry.pinned = !!payload.pinned; + return entry; + }, limit); + return mutationResponse( + await writeHistory(history), + {}, + "HISTORY_PIN_FAILED", + "Failed to update local history." + ); + }); + } + + async function readCopyHistoryLimit(value, currentLength) { + 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) { + return utils.normalizeCopyHistoryLimit(Math.max(1, currentLength)); + } + } + + function trimLocalHistory(limit) { + return enqueueStorage(async function () { + const normalizedLimit = utils.normalizeCopyHistoryLimit(limit); + const history = trimHistoryToStorageBudget( + utils.pushHistoryEntry(await readHistory(), null, normalizedLimit) + ); + return writeHistory(history); + }); + } + + async function readHistory() { + const current = await utils.safeStorageGet("local", { copyHistory: [] }); + return Array.isArray(current.copyHistory) + ? current.copyHistory.map(utils.normalizeHistoryEntry).filter(Boolean) + : []; + } + + function writeHistory(copyHistory) { + return utils.safeStorageSet("local", { + copyHistory: Array.isArray(copyHistory) ? copyHistory : [], + }); + } + + return { + handleRuntimeMessage, + trimHistory: trimLocalHistory, + cleanupLegacyCompanionState: function () { + return cleanupLegacyCompanionState(utils); + }, + }; +} + +function trimHistoryToStorageBudget(history) { + const result = Array.isArray(history) ? history.slice() : []; + const maxBytes = 4 * 1024 * 1024; + let serializedBytes = new TextEncoder().encode(JSON.stringify(result)).length; + while (serializedBytes > maxBytes && result.length > 1) { + let indexToRemove = result.length - 1; + for (let index = result.length - 1; index >= 0; index -= 1) { + if (!result[index].pinned) { + indexToRemove = index; + break; + } + } + result.splice(indexToRemove, 1); + serializedBytes = new TextEncoder().encode(JSON.stringify(result)).length; + } + return result; +} + +async function cleanupLegacyCompanionState(utils) { + const storageRemoved = await utils.safeChromeAsync(async function () { + if (!chrome.storage || !chrome.storage.local || typeof chrome.storage.local.remove !== "function") { + return false; + } + await chrome.storage.local.remove(LEGACY_OUTBOX_KEY); + return true; + }, false); + + const factory = (utils && utils.indexedDB) + || (typeof globalThis !== "undefined" && globalThis.indexedDB); + if (!factory || typeof factory.deleteDatabase !== "function") { + return { storageRemoved: !!storageRemoved, databaseRemoved: false }; + } + + const databaseRemoved = await new Promise(function (resolve, reject) { + const request = factory.deleteDatabase(LEGACY_OUTBOX_DB_NAME); + request.onsuccess = function () { resolve(true); }; + request.onerror = function () { + reject(request.error || new Error("Could not remove obsolete companion sync data.")); + }; + request.onblocked = function () { + reject(new Error("Removing obsolete companion sync data was blocked.")); + }; + }); + return { storageRemoved: !!storageRemoved, databaseRemoved }; +} + +function mutationResponse(stored, payload, code, message) { + return { + ok: !!stored, + payload: { + local: Object.assign({ ok: !!stored }, payload || {}), + }, + error: stored ? undefined : { + code, + message, + recoverable: true, + }, + }; +} + +function badPayload(message) { + return { + ok: false, + error: { + code: "BAD_PAYLOAD", + message, + recoverable: false, + }, + }; +} + +module.exports = { + LEGACY_OUTBOX_DB_NAME, + LEGACY_OUTBOX_KEY, + cleanupLegacyCompanionState, + createLocalHistoryController, + trimHistoryToStorageBudget, +}; diff --git a/src/background/registration.js b/src/background/registration.js new file mode 100644 index 0000000..7eb896c --- /dev/null +++ b/src/background/registration.js @@ -0,0 +1,92 @@ +let initializationQueue = Promise.resolve(); + +async function ensureSettings(utils) { + const current = await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS); + const merged = utils.mergeSettings(current); + const changed = Object.keys(merged).some(function (key) { + return JSON.stringify(current[key]) !== JSON.stringify(merged[key]); + }); + if (changed) { + await utils.safeStorageSet("sync", merged); + } + return merged; +} + +async function syncContentScriptRegistration(utils, settings, contentScriptId) { + if (!utils.isExtensionContextValid()) { + return; + } + + const definition = { + id: contentScriptId, + matches: ["http://*/*", "https://*/*"], + excludeMatches: utils.buildExcludeMatches(settings.excludedDomains), + js: ["shared.js", "menu.js"], + runAt: "document_start", + persistAcrossSessions: true, + }; + const registered = await utils.safeChromeAsync(function () { + return chrome.scripting.getRegisteredContentScripts({ ids: [contentScriptId] }); + }, []); + + await utils.safeChromeAsync(function () { + return registered && registered.length + ? chrome.scripting.updateContentScripts([definition]) + : chrome.scripting.registerContentScripts([definition]); + }, 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); + } + })); +} + +function initializeExtension(utils, contentScriptId, trimHistory) { + const next = initializationQueue.then(async function () { + if (!utils.isExtensionContextValid()) { + return; + } + + const settings = await ensureSettings(utils); + await syncContentScriptRegistration(utils, settings, contentScriptId); + await injectContentScriptsIntoOpenTabs(utils, settings); + await trimHistory(settings.copyHistoryLimit); + }); + initializationQueue = next.catch(function () {}); + return next; +} + +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..379079a --- /dev/null +++ b/src/background/shortcut.js @@ -0,0 +1,69 @@ +async function triggerShortcutCopy(utils, saveAnalyticsEvent) { + const settings = utils.mergeSettings(await utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS)); + if (!settings.keyboardShortcutEnabled) { + return { copied: false, reason: "disabled" }; + } + + const tabs = await utils.safeTabsQuery({ active: true, lastFocusedWindow: true }); + const activeTab = tabs[0]; + if (!activeTab || !activeTab.id || !activeTab.url) { + return { copied: false, reason: "no-active-tab" }; + } + + 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 { copied: false, reason: "excluded-host" }; + } + + let response = null; + try { + response = await chrome.tabs.sendMessage(activeTab.id, { + type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", + }); + } catch (error) { + if (utils.isExtensionContextInvalidatedError(error)) { + return { copied: false, reason: "context-invalidated" }; + } + + try { + await utils.safeExecuteScript({ + target: { tabId: activeTab.id }, + files: ["shared.js", "menu.js"], + }); + + response = await chrome.tabs.sendMessage(activeTab.id, { + type: "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT", + }); + } catch (secondError) { + if (utils.isExtensionContextInvalidatedError(secondError)) { + return { copied: false, reason: "context-invalidated" }; + } + return { copied: false, reason: "send-failed" }; + } + } + + if (!isShortcutCopySuccess(response)) { + return { + copied: false, + reason: response && response.reason ? response.reason : "copy-not-confirmed", + }; + } + + return { copied: true, reason: "copied" }; +} + +function isShortcutCopySuccess(response) { + return !!(response && response.ok && response.copied); +} + +module.exports = { + triggerShortcutCopy, + isShortcutCopySuccess, +}; diff --git a/src/content/bootstrap.js b/src/content/bootstrap.js new file mode 100644 index 0000000..206aa29 --- /dev/null +++ b/src/content/bootstrap.js @@ -0,0 +1,253 @@ +(function () { + 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, sender, sendResponse) { + if (!isExtensionUsable()) { + return false; + } + + if (!message || message.type != "COPY_TEXT_WITHOUT_SELECTING_SHORTCUT") { + return false; + } + + if (isCurrentHostExcluded() || !state.settings.keyboardShortcutEnabled) { + if (isCurrentHostExcluded()) { + helpers.saveAnalyticsEvent({ + type: "blockedExcluded", + hostname: window.location.hostname, + toastKind: "status", + }); + } + sendResponse({ ok: false, copied: false, reason: "disabled-or-excluded" }); + return false; + } + + 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.")); + sendResponse({ ok: false, copied: false, reason: "no-target" }); + return false; + } + + if (helpers.shouldIgnoreElement(shortcutTarget)) { + helpers.saveAnalyticsEvent({ + type: "editableSkipped", + hostname: window.location.hostname, + toastKind: "status", + }); + helpers.showStatusToast(t("unsupported_surface_status", "Editing surface skipped")); + sendResponse({ ok: false, copied: false, reason: "ignored-target" }); + return false; + } + + helpers.copyCommand(shortcutTarget, "shortcut", { + preferSelection: true, + }).then(function (copied) { + sendResponse({ ok: !!copied, copied: !!copied }); + }).catch(function (error) { + if (handleExtensionContextError(error)) { + sendResponse({ ok: false, copied: false, reason: "context-invalidated" }); + return; + } + console.error("Shortcut copy failed.", error); + sendResponse({ ok: false, copied: false, reason: "copy-failed" }); + }); + return true; + } + + function attachExtensionListeners() { + if (state.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", events.handleClick, true); + 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); + } + + function removeDomListeners() { + document.removeEventListener("click", events.handleClick, true); + 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 handleExtensionContextError(error) { + if (!utils.isExtensionContextInvalidatedError(error)) { + return false; + } + + dispose(); + return true; + } + + function dispose() { + if (state.disposed) { + return; + } + + state.disposed = true; + state.extensionContextInvalidated = true; + + if (state.hoverState.previewAnimationFrame) { + cancelAnimationFrame(state.hoverState.previewAnimationFrame); + state.hoverState.previewAnimationFrame = 0; + } + + removeExtensionListeners(); + removeDomListeners(); + helpers.hidePreview(); + + if (globalThis.__copyTextWithAltClickContentScriptState === state.scriptState) { + delete globalThis.__copyTextWithAltClickContentScriptState; + } + } + + function updateSettings() { + if (!isExtensionUsable()) { + return; + } + + utils.safeStorageGet("sync", utils.DEFAULT_SETTINGS).then(function (items) { + if (!isExtensionUsable()) { + return; + } + + state.settings = utils.mergeSettings(items); + + if (isCurrentHostExcluded() || !state.hoverState.previewModifierActive) { + helpers.hidePreview(); + } else if (helpers.shouldShowPreview()) { + helpers.schedulePreviewUpdate(); + } + }).catch(function (error) { + if (handleExtensionContextError(error)) { + return; + } + + console.warn("Updating settings failed.", error); + }); + } +})(); diff --git a/src/content/clipboard.js b/src/content/clipboard.js new file mode 100644 index 0000000..743582a --- /dev/null +++ b/src/content/clipboard.js @@ -0,0 +1,96 @@ +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 false; + } + return executePrecisionCopy(precisionTarget, source); + } + + async function executePrecisionCopy(precisionTarget, source) { + const text = extraction.getText(precisionTarget); + if (!text) { + return false; + } + + 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"); + return true; + } + + 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")) { + throw new Error("Copy failed."); + } + } finally { + context.state.suppressNativeCopyTracking = false; + textArea.remove(); + } + } + + return { + copyCommand, + executePrecisionCopy, + copy, + }; +} + +module.exports = { + createContentClipboard, +}; diff --git a/src/content/events.js b/src/content/events.js new file mode 100644 index 0000000..be0cd84 --- /dev/null +++ b/src/content/events.js @@ -0,0 +1,229 @@ +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; + } + + claimCopyGesture(event); + helpers.syncPointerState(event); + const composedTarget = event.composedPath ? event.composedPath()[0] : event.target; + const deepTarget = helpers.getElementNode(helpers.pierceShadowDOM(composedTarget, event.clientX, event.clientY)); + hoverState.hoveredElement = deepTarget; + 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); + }); + } + + 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) { + 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/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 new file mode 100644 index 0000000..d9d3343 --- /dev/null +++ b/src/content/helpers.js @@ -0,0 +1,31 @@ +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"); + +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 = { + createContentHelpers, +}; 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..4d89fbd --- /dev/null +++ b/src/content/persistence.js @@ -0,0 +1,93 @@ +function createContentPersistence(context) { + const utils = context.utils; + + function settings() { + return context.state.settings; + } + + async function saveHistory(text, result, source, isSelectionBased) { + 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 backgroundSaved = await saveHistoryThroughBackground(entry); + if (!backgroundSaved && utils.isExtensionContextValid()) { + console.warn("Saving copy history through the background service worker failed."); + } + } + + async function saveHistoryThroughBackground(entry) { + 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, + limit: settings().copyHistoryLimit, + }, + }); + return !!(response && response.ok); + } catch (error) { + return false; + } + } + + 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/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..9cca2db --- /dev/null +++ b/src/options/history-view.js @@ -0,0 +1,493 @@ +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", { + copyAnalytics: context.utils.DEFAULT_ANALYTICS, + }); + + const history = await loadHistory(); + 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, item.text); + }); + + 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); + + const smartActions = createSmartActions(item); + if (smartActions) { + wrapper.appendChild(smartActions); + } + + 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")); + } + + 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")); + } + + 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", item.text); + } catch (error) { + context.ui.showStatus(error.message || "Clipboard error"); + } + } + + async function deleteHistoryItem(historyId, text) { + context.state.selectedHistoryIds.delete(historyId); + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_DELETE", { + id: historyId, + text: text || "", + }); + await renderHistoryAndAnalytics(); + context.ui.showStatus(context.ui.t("history_deleted", "History item deleted")); + } + + 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", { + copyAnalytics: context.utils.DEFAULT_ANALYTICS, + }); + const hostname = item.hostname || context.utils.getHostnameFromUrl(item.url || ""); + const text = replayText || item.text; + const entry = { + text, + snippet: context.utils.getTextSnippet(text), + source: "history", + mode: "copy", + url: item.url || "", + hostname: hostname, + format: context.utils.detectSmartFormat(text), + }; + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_ADD", { + entry, + limit: context.state.settings.copyHistoryLimit, + }); + const nextAnalytics = context.utils.recordAnalyticsEvent(current.copyAnalytics, { + type: analyticsType, + hostname: hostname, + toastKind: "status", + }); + + await context.utils.safeStorageSet("local", { + copyAnalytics: nextAnalytics, + }); + } + + async function clearHistory() { + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_CLEAR"); + 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 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(); + context.ui.showStatus(context.ui.t("history_bulk_done", "Bulk action completed")); + } + + async function bulkReplayHistory() { + if (!context.state.selectedHistoryIds.size) { + return; + } + + 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(); + 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"); + } + } + + 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; + 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 new file mode 100644 index 0000000..8612087 --- /dev/null +++ b/src/options/index.js @@ -0,0 +1,231 @@ +(function () { + const { createOptionsUi } = require("./ui.js"); + const { createTabController } = require("./tabs.js"); + 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; + } + + const state = { + settings: utils.mergeSettings(), + historyFilters: { + search: "", + source: "all", + mode: "all", + hostname: "all", + sort: "newest", + group: "none", + }, + bulkSelectionMode: false, + selectedHistoryIds: new Set(), + }; + + function isExtensionUsable() { + return utils.isExtensionContextValid(); + } + + function reportOptionsError(message, error) { + if (utils.isExtensionContextInvalidatedError(error)) { + return true; + } + + console.warn(message, error); + 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() { + if (!isExtensionUsable()) { + return; + } + + try { + await context.settingsForm.restoreOptions(); + context.ui.applyMessages(); + bindEvents(); + await context.domains.renderExcludedDomains(); + await context.history.renderHistoryAndAnalytics(); + } catch (error) { + if (reportOptionsError("Initializing the options page failed.", error)) { + return; + } + } + } + + function bindEvents() { + context.tabs.bindTabEvents(); + + 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", 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", 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(); + context.domains.addDomainFromInput().catch(function (error) { + reportOptionsError("Adding an excluded domain failed.", error); + }); + } + }); + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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", 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 () { + 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); + }); + }); + + utils.addListenerSafely(chrome.storage.onChanged, function (changes, areaName) { + if (!isExtensionUsable()) { + return; + } + + if (areaName == "sync") { + 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)) { + context.history.renderHistoryAndAnalytics().catch(function (error) { + reportOptionsError("Refreshing options history or analytics failed.", error); + }); + } + }); + } +})(); 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, +}; 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..dad4e34 --- /dev/null +++ b/src/popup/history-view.js @@ -0,0 +1,252 @@ +function createPopupHistoryView(context) { + async function renderHistory() { + const history = await loadHistory(); + 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, item.text).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); + + const smartActions = createSmartActions(item); + if (smartActions) { + wrapper.appendChild(smartActions); + } + + 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")); + } + + 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; + } + + 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", 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, replayText) { + const current = await context.utils.safeStorageGet("local", { + copyAnalytics: context.utils.DEFAULT_ANALYTICS, + }); + const hostname = item.hostname || context.utils.getHostnameFromUrl(item.url || ""); + 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, + format: context.utils.detectSmartFormat(text), + }; + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_ADD", { + entry, + limit: context.getSettings().copyHistoryLimit, + }); + const nextAnalytics = context.utils.recordAnalyticsEvent(current.copyAnalytics, { + type: analyticsType, + hostname: hostname, + toastKind: "status", + }); + + await context.utils.safeStorageSet("local", { + copyAnalytics: nextAnalytics, + }); + } + + async function clearHistory() { + await sendHistoryMessage("COPY_TEXT_LOCAL_HISTORY_CLEAR"); + await renderHistory(); + context.ui.showStatus(context.ui.t("copy_history_cleared", "History cleared")); + } + + 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, + }; +} + +module.exports = { + createPopupHistoryView, +}; diff --git a/src/popup/index.js b/src/popup/index.js new file mode 100644 index 0000000..324952e --- /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/src/shared/core.js b/src/shared/core.js new file mode 100644 index 0000000..f781f65 --- /dev/null +++ b/src/shared/core.js @@ -0,0 +1,1001 @@ +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 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", + 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: "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.", + 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: "Đã 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: "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: "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: "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 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: "Đ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: "Đã 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" + }; + + function normalizeDomain(value) { + var trimmed = String(value || "").trim().toLowerCase(); + if (!trimmed) return ""; + var normalized = trimmed.replace(/^\*\./, "").replace(/^\.+/, ""); + try { + var candidate = normalized.includes("://") ? normalized : "https://" + normalized; + normalized = new URL(candidate).hostname.toLowerCase(); + } catch (error) { + return ""; + } + normalized = normalized.replace(/^\*\./, "").replace(/\.$/, ""); + if (!normalized || normalized.length > 253 || normalized.includes("*")) return ""; + if (normalized.startsWith("[") && normalized.endsWith("]")) return normalized; + if (!normalized.split(".").every(function (label) { + return !!label && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label); + })) return ""; + return normalized; + } + + 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 + "/*"); + if (!domain.startsWith("[")) 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 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; + 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 === 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, + 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"), + format: normalizeSmartFormat(entry.format || detectSmartFormat(normalizedText)), + 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 === undefined || limit === null || limit === "" ? DEFAULT_COPY_HISTORY_LIMIT : limit); + var normalizedEntry = normalizeHistoryEntry(entry); + if (!normalizedEntry || maxItems === 0) return trimHistoryEntries(nextEntries, 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 trimHistoryEntries(nextEntries, maxItems); + } + + function updateHistoryEntry(entries, historyId, updater, limit) { + 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 || {})); + }).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) { + 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(); + } + + 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; + } + } + +module.exports = { + DEFAULT_SETTINGS: DEFAULT_SETTINGS, + 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, + 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, + 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, + 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/content-events.test.js b/tests/content-events.test.js new file mode 100644 index 0000000..8830ea1 --- /dev/null +++ b/tests/content-events.test.js @@ -0,0 +1,43 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createContentEvents } = require("../src/content/events.js"); + +test("click copies the current event target instead of a stale preview target", async function () { + const staleTarget = { id: "stale" }; + const currentTarget = { id: "current" }; + let copiedTarget; + const hoverState = { lastRenderedTarget: staleTarget }; + const events = createContentEvents({ + utils: { + getCopyMode: function () { return "copy"; }, + }, + state: { + settings: { metaKey: "Alt" }, + hoverState, + }, + helpers: { + shouldIgnoreElement: function () { return false; }, + syncPointerState: function () {}, + getElementNode: function (element) { return element; }, + pierceShadowDOM: function (element) { return element; }, + copyCommand: async function (target) { copiedTarget = target; }, + }, + isExtensionUsable: function () { return true; }, + isCurrentHostExcluded: function () { return false; }, + handleExtensionContextError: function () { return false; }, + }); + + events.handleClick({ + target: currentTarget, + clientX: 1, + clientY: 2, + cancelable: true, + composedPath: function () { return [currentTarget]; }, + preventDefault: function () {}, + stopImmediatePropagation: function () {}, + }); + await new Promise(function (resolve) { setImmediate(resolve); }); + + assert.equal(copiedTarget, currentTarget); + assert.equal(hoverState.hoveredElement, currentTarget); +}); diff --git a/tests/content-storage.test.js b/tests/content-storage.test.js new file mode 100644 index 0000000..1885508 --- /dev/null +++ b/tests/content-storage.test.js @@ -0,0 +1,82 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const core = require("../src/shared/core.js"); +const { createContentClipboard } = require("../src/content/clipboard.js"); +const { createContentPersistence } = require("../src/content/persistence.js"); + +function replaceGlobal(name, value) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + Object.defineProperty(globalThis, name, { configurable: true, value }); + return function restore() { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + delete globalThis[name]; + } + }; +} + +test("clipboard fallback rejects instead of recording a false success", async function () { + const restoreNavigator = replaceGlobal("navigator", {}); + const restoreDocument = replaceGlobal("document", { + body: { appendChild: function () {} }, + createElement: function () { + return { + style: {}, + select: function () {}, + remove: function () {}, + }; + }, + execCommand: function () { return false; }, + }); + const context = { + state: { + hoverState: {}, + suppressNativeCopyTracking: false, + }, + }; + const clipboard = createContentClipboard(context, {}, {}, {}, {}); + + try { + await assert.rejects(clipboard.copy("text", ""), /Copy failed/); + } finally { + restoreDocument(); + restoreNavigator(); + } +}); + +test("content history keeps the background service worker as the only writer", async function () { + const restoreChrome = replaceGlobal("chrome", { + runtime: { + id: "test-extension", + sendMessage: async function () { throw new Error("background unavailable"); }, + }, + }); + const restoreWindow = replaceGlobal("window", { + location: { + href: "https://example.com/page", + hostname: "example.com", + }, + }); + let localAccesses = 0; + const utils = Object.assign({}, core, { + safeStorageGet: async function () { localAccesses += 1; return { copyHistory: [] }; }, + safeStorageSet: async function () { localAccesses += 1; return true; }, + }); + const persistence = createContentPersistence({ + utils, + state: { settings: core.mergeSettings() }, + handleExtensionContextError: function () { return false; }, + }); + const originalWarn = console.warn; + console.warn = function () {}; + + try { + await persistence.saveHistory("text", "copied", "click", false); + assert.equal(localAccesses, 0); + } finally { + console.warn = originalWarn; + restoreWindow(); + restoreChrome(); + } +}); diff --git a/tests/e2e/extension.spec.js b/tests/e2e/extension.spec.js new file mode 100644 index 0000000..68963cd --- /dev/null +++ b/tests/e2e/extension.spec.js @@ -0,0 +1,380 @@ +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"; + +let server; +let baseUrl; +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, baseUrl).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, reject) { + function handleError(error) { + reject(error); + } + server.once("error", handleError); + server.listen(0, HOST, function () { + server.off("error", handleError); + resolve(); + }); + }); + const address = server.address(); + baseUrl = `http://${HOST}:${address.port}`; +} + +async function stopFixtureServer() { + if (!server) { + return; + } + + if (server.listening) { + await new Promise(function (resolve) { + server.close(resolve); + }); + } + server = null; + baseUrl = 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: baseUrl }); + + let serviceWorker = context.serviceWorkers()[0]; + if (!serviceWorker) { + serviceWorker = await context.waitForEvent("serviceworker"); + } + + extensionId = new URL(serviceWorker.url()).host; + await serviceWorker.evaluate(function () { + return chrome.storage.sync.set({ copyTextE2EInitialize: Date.now() }); + }); + await expect.poll(function () { + return serviceWorker.evaluate(async function () { + const scripts = await chrome.scripting.getRegisteredContentScripts({ + ids: ["copy-text-with-alt-click-content"], + }); + return scripts.length; + }); + }, { timeout: 15000 }).toBe(1); +} + +async function closeExtensionContext() { + try { + if (context) { + await context.close(); + } + } finally { + 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(`${baseUrl}/${relativePath}`); + return { page, consoleMessages }; +} + +async function openExtensionPage(relativePath) { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/${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(); + 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 () { + try { + await startFixtureServer(); + await launchExtensionContext(); + } catch (error) { + await closeExtensionContext(); + await stopFixtureServer(); + throw error; + } +}); + +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("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("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" })); + + 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 () { + 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 expect(popupPage.locator("#open_companion")).toHaveCount(0); + 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/local-history.test.js b/tests/local-history.test.js new file mode 100644 index 0000000..070a342 --- /dev/null +++ b/tests/local-history.test.js @@ -0,0 +1,239 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const core = require("../src/shared/core.js"); +const { + LEGACY_OUTBOX_DB_NAME, + LEGACY_OUTBOX_KEY, + cleanupLegacyCompanionState, + createLocalHistoryController, +} = require("../src/background/local-history.js"); + +function sendRuntimeMessage(controller, message) { + return new Promise(function (resolve) { + controller.handleRuntimeMessage(message, {}, resolve); + }); +} + +function createStorage(initial, options) { + let state = Object.assign({ copyHistory: [] }, structuredClone(initial || {})); + const config = options || {}; + return { + utils: Object.assign({}, core, { + safeStorageGet: async function (area, defaults) { + if (config.delay) { + await new Promise(function (resolve) { setTimeout(resolve, config.delay); }); + } + if (area === "local") { + return Object.assign({}, defaults, structuredClone(state)); + } + if (area === "sync") { + if (config.syncThrows) { + throw new Error("sync storage unavailable"); + } + return Object.assign({}, core.DEFAULT_SETTINGS, config.sync || {}); + } + throw new Error("Unexpected storage area: " + area); + }, + safeStorageSet: async function (area, value) { + assert.equal(area, "local"); + if (config.delay) { + await new Promise(function (resolve) { setTimeout(resolve, config.delay); }); + } + if (config.writeFails) { + return false; + } + state = Object.assign({}, state, structuredClone(value)); + return true; + }, + }), + read: function () { + return structuredClone(state); + }, + }; +} + +test("local history stays disabled when the configured limit is zero", async function () { + const storage = createStorage(); + const controller = createLocalHistoryController(storage.utils); + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { + limit: 0, + entry: { text: "not stored", createdAt: 1 }, + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual(response.payload.local, { + ok: true, + stored: false, + reason: "local-history-disabled", + }); + assert.deepEqual(storage.read().copyHistory, []); +}); + +test("local history serializes concurrent writes", async function () { + const storage = createStorage({}, { delay: 5 }); + const controller = createLocalHistoryController(storage.utils); + + await Promise.all([ + sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { limit: 10, entry: { text: "first", createdAt: 1 } }, + }), + sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { limit: 10, entry: { text: "second", createdAt: 2 } }, + }), + ]); + + assert.deepEqual(storage.read().copyHistory.map(function (entry) { + return entry.text; + }), ["second", "first"]); +}); + +test("history list filters and limits local entries", async function () { + const storage = createStorage({ + copyHistory: [ + { id: "a", text: "Alpha JSON", createdAt: 3, hostname: "example.com" }, + { id: "b", text: "Beta SQL", createdAt: 2, hostname: "example.net" }, + { id: "c", text: "Gamma JSON", createdAt: 1, hostname: "example.org" }, + ], + }); + const controller = createLocalHistoryController(storage.utils); + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_LIST", + payload: { query: "json", limit: 1 }, + }); + + assert.equal(response.ok, true); + assert.equal(response.payload.mode, "local"); + assert.equal(response.payload.pending, 0); + assert.deepEqual(response.payload.history.map(function (entry) { + return entry.text; + }), ["Alpha JSON"]); +}); + +test("pin, bulk delete, and clear mutate local history", async function () { + const storage = createStorage({ + copyHistory: [ + { id: "a", text: "Alpha", createdAt: 2 }, + { id: "b", text: "Beta", createdAt: 1 }, + ], + }, { + sync: { copyHistoryLimit: 9999 }, + }); + const controller = createLocalHistoryController(storage.utils); + + assert.equal((await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_PIN", + payload: { id: "a", pinned: true }, + })).ok, true); + assert.equal(storage.read().copyHistory.find(function (entry) { + return entry.id === "a"; + }).pinned, true); + + assert.equal((await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_DELETE", + payload: { ids: ["a", "b"] }, + })).ok, true); + assert.deepEqual(storage.read().copyHistory, []); + + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { limit: 10, entry: { text: "new", createdAt: 3 } }, + }); + assert.equal((await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_CLEAR", + })).ok, true); + assert.deepEqual(storage.read().copyHistory, []); +}); + +test("pin keeps the current history length when sync settings are unavailable", async function () { + const entries = Array.from({ length: 60 }, function (_, index) { + return { id: String(index), text: "item-" + index, createdAt: 100 - index }; + }); + const storage = createStorage({ copyHistory: entries }, { syncThrows: true }); + const controller = createLocalHistoryController(storage.utils); + + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_PIN", + payload: { id: "59", pinned: true }, + }); + + assert.equal(response.ok, true); + assert.equal(storage.read().copyHistory.length, 60); + assert.equal(storage.read().copyHistory.find(function (entry) { + return entry.id === "59"; + }).pinned, true); +}); + +test("local history reports storage write failures", async function () { + const storage = createStorage({}, { writeFails: true }); + const controller = createLocalHistoryController(storage.utils); + const response = await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { limit: 10, entry: { text: "lost", createdAt: 1 } }, + }); + + assert.equal(response.ok, false); + assert.equal(response.error.code, "HISTORY_ADD_FAILED"); + assert.equal(response.payload.local.ok, false); +}); + +test("local history trims multibyte payloads to its storage budget on add", async function () { + const storage = createStorage(); + const controller = createLocalHistoryController(storage.utils); + const largeText = "界".repeat(800000); + + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { limit: 10, entry: { text: largeText + "first", createdAt: 1 } }, + }); + await sendRuntimeMessage(controller, { + type: "COPY_TEXT_LOCAL_HISTORY_ADD", + payload: { limit: 10, entry: { text: largeText + "second", createdAt: 2 } }, + }); + + const history = storage.read().copyHistory; + assert.equal(history.length, 1); + assert.match(history[0].text, /second$/); + assert.ok(new TextEncoder().encode(JSON.stringify(history)).length <= 4 * 1024 * 1024); +}); + +test("upgrade cleanup removes obsolete companion outbox storage", async function () { + const originalChrome = global.chrome; + const removedKeys = []; + let deletedDatabase = ""; + global.chrome = { + runtime: { id: "test-extension" }, + storage: { + local: { + remove: async function (key) { + removedKeys.push(key); + }, + }, + }, + }; + + const indexedDB = { + deleteDatabase: function (name) { + deletedDatabase = name; + const request = {}; + setTimeout(function () { + request.onsuccess(); + }, 0); + return request; + }, + }; + const utils = Object.assign({}, core, { indexedDB }); + + try { + const result = await cleanupLegacyCompanionState(utils); + assert.deepEqual(result, { storageRemoved: true, databaseRemoved: true }); + assert.deepEqual(removedKeys, [LEGACY_OUTBOX_KEY]); + assert.equal(deletedDatabase, LEGACY_OUTBOX_DB_NAME); + } finally { + global.chrome = originalChrome; + } +}); diff --git a/tests/registration.test.js b/tests/registration.test.js new file mode 100644 index 0000000..c044469 --- /dev/null +++ b/tests/registration.test.js @@ -0,0 +1,86 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const core = require("../src/shared/core.js"); +const { + ensureSettings, + initializeExtension, + syncContentScriptRegistration, +} = require("../src/background/registration.js"); + +test("ensureSettings does not rewrite already normalized settings", async function () { + let writes = 0; + const settings = core.mergeSettings(); + const utils = Object.assign({}, core, { + safeStorageGet: async function () { return settings; }, + safeStorageSet: async function () { writes += 1; return true; }, + }); + + assert.deepEqual(await ensureSettings(utils), settings); + assert.equal(writes, 0); +}); + +test("content registration updates in place with normalized exclude matches", async function () { + const originalChrome = global.chrome; + let definition; + let unregisterCalls = 0; + global.chrome = { + runtime: { id: "test-extension" }, + scripting: { + getRegisteredContentScripts: async function () { return [{ id: "content" }]; }, + updateContentScripts: async function (items) { definition = items[0]; }, + registerContentScripts: async function () { throw new Error("unexpected register"); }, + unregisterContentScripts: async function () { unregisterCalls += 1; }, + }, + }; + const utils = Object.assign({}, core, { + safeChromeAsync: async function (work) { return work(); }, + }); + + try { + await syncContentScriptRegistration(utils, { + excludedDomains: ["*.example.com", "bad host"], + }, "content"); + assert.deepEqual(definition.excludeMatches, [ + "*://example.com/*", + "*://*.example.com/*", + ]); + assert.equal(unregisterCalls, 0); + } finally { + global.chrome = originalChrome; + } +}); + +test("extension initialization is serialized", async function () { + const originalChrome = global.chrome; + let activeRegistrations = 0; + let maxActiveRegistrations = 0; + global.chrome = { + runtime: { id: "test-extension" }, + scripting: { + getRegisteredContentScripts: async function () { return [{ id: "content" }]; }, + updateContentScripts: async function () { + activeRegistrations += 1; + maxActiveRegistrations = Math.max(maxActiveRegistrations, activeRegistrations); + await new Promise(function (resolve) { setTimeout(resolve, 5); }); + activeRegistrations -= 1; + }, + }, + }; + const settings = core.mergeSettings(); + const utils = Object.assign({}, core, { + safeStorageGet: async function () { return settings; }, + safeStorageSet: async function () { return true; }, + safeChromeAsync: async function (work) { return work(); }, + safeTabsQuery: async function () { return []; }, + }); + + try { + await Promise.all([ + initializeExtension(utils, "content", async function () {}), + initializeExtension(utils, "content", async function () {}), + ]); + assert.equal(maxActiveRegistrations, 1); + } finally { + global.chrome = originalChrome; + } +}); diff --git a/tests/release-utils.test.js b/tests/release-utils.test.js new file mode 100644 index 0000000..2bef872 --- /dev/null +++ b/tests/release-utils.test.js @@ -0,0 +1,79 @@ +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; + }); + const expectedOutputPaths = releaseUtils.getExpectedChromeOutputPaths(); + + assert.ok(relativePaths.includes("manifest.json")); + 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"); + })); + assert.ok(!relativePaths.includes("manifest.firefox.json")); + assert.ok(!relativePaths.some(function (entry) { + return entry.startsWith(".omx/"); + })); +}); + +test("Chrome release does not request native messaging", function () { + const manifest = releaseUtils.readJson(path.join(__dirname, "..", "manifest.json")); + assert.ok(!manifest.permissions.includes("nativeMessaging")); +}); + +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 new file mode 100644 index 0000000..2de0bac --- /dev/null +++ b/tests/shared.test.js @@ -0,0 +1,405 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +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"); + assert.equal(utils.normalizeDomain(".figma.com"), "figma.com"); + assert.equal(utils.normalizeDomain("*.example.com"), "example.com"); + assert.equal(utils.normalizeDomain("https://*.example.com/path"), "example.com"); + assert.equal(utils.normalizeDomain("sub.example.com/path?q=1"), "sub.example.com"); + assert.equal(utils.normalizeDomain("bad host"), ""); + assert.equal(utils.normalizeDomain("foo_.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("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..."); +}); + +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"); +}); + +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; +}); diff --git a/tests/shortcut.test.js b/tests/shortcut.test.js new file mode 100644 index 0000000..b6df851 --- /dev/null +++ b/tests/shortcut.test.js @@ -0,0 +1,129 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + triggerShortcutCopy, + isShortcutCopySuccess, +} = require("../src/background/shortcut.js"); + +function createShortcutUtils(overrides) { + const config = Object.assign({ + keyboardShortcutEnabled: true, + activeTab: { id: 7, url: "https://example.com/article" }, + excluded: false, + }, overrides || {}); + + return { + DEFAULT_SETTINGS: {}, + mergeSettings: function () { + return { + keyboardShortcutEnabled: config.keyboardShortcutEnabled, + excludedDomains: [], + }; + }, + safeStorageGet: async function () { + return {}; + }, + safeTabsQuery: async function () { + return config.activeTab ? [config.activeTab] : []; + }, + safeExecuteScript: async function (details) { + config.events.push({ type: "executeScript", details: details }); + return []; + }, + getHostnameFromUrl: function (url) { + return new URL(url).hostname; + }, + isExcludedHost: function () { + return config.excluded; + }, + isExtensionContextInvalidatedError: function () { + return false; + }, + events: config.events, + }; +} + +test("isShortcutCopySuccess requires an explicit copied ack", function () { + assert.equal(isShortcutCopySuccess({ ok: true, copied: true }), true); + assert.equal(isShortcutCopySuccess({ ok: true, copied: false }), false); + assert.equal(isShortcutCopySuccess(undefined), false); +}); + +test("shortcut copy completes after content confirms copy", async function () { + const originalChrome = global.chrome; + const events = []; + global.chrome = { + tabs: { + sendMessage: async function (tabId, message) { + events.push({ type: "sendMessage", tabId: tabId, message: message }); + return { ok: true, copied: true }; + }, + }, + }; + + try { + const result = await triggerShortcutCopy(createShortcutUtils({ events: events }), async function () {}); + + assert.deepEqual(events.map(function (event) { return event.type; }), ["sendMessage"]); + assert.equal(result.copied, true); + assert.equal(result.reason, "copied"); + } finally { + global.chrome = originalChrome; + } +}); + +test("shortcut copy reports when content does not copy", async function () { + const originalChrome = global.chrome; + const events = []; + global.chrome = { + tabs: { + sendMessage: async function () { + events.push({ type: "sendMessage" }); + return { ok: false, copied: false, reason: "no-target" }; + }, + }, + }; + + try { + const result = await triggerShortcutCopy(createShortcutUtils({ events: events }), async function () {}); + + assert.deepEqual(events.map(function (event) { return event.type; }), ["sendMessage"]); + assert.equal(result.copied, false); + assert.equal(result.reason, "no-target"); + } finally { + global.chrome = originalChrome; + } +}); + +test("shortcut copy injects the content script before retrying", async function () { + const originalChrome = global.chrome; + const events = []; + let attempts = 0; + global.chrome = { + tabs: { + sendMessage: async function () { + attempts += 1; + events.push({ type: "sendMessage", attempts: attempts }); + if (attempts === 1) { + throw new Error("Could not establish connection."); + } + return { ok: true, copied: true }; + }, + }, + }; + + try { + const result = await triggerShortcutCopy(createShortcutUtils({ events: events }), async function () {}); + + assert.deepEqual(events.map(function (event) { return event.type; }), [ + "sendMessage", + "executeScript", + "sendMessage", + ]); + assert.equal(result.copied, true); + assert.equal(result.reason, "copied"); + } finally { + global.chrome = originalChrome; + } +});