Compare commits

..

10 commits
0.1.0 ... main

Author SHA1 Message Date
Nymbo
50af0005a0 Revert copied table highlighting 2026-05-20 14:42:54 -04:00
Nymbo
22b14c40f5 Highlight copied Markdown tables 2026-05-20 13:12:22 -04:00
Nymbo
d8a5d19576 Address submission release checks 2026-05-12 17:19:18 -04:00
Nymbo
abb1cd956a Remove Obsidian from plugin description 2026-05-12 17:07:21 -04:00
Nymbo
1fad04603b Rename plugin to Copy Highlighter 2026-05-12 16:55:24 -04:00
Nymbo
a2fbbeae54 Prepare plugin for community submission 2026-05-12 16:50:00 -04:00
Nymbo
15fa41c5e3 Modernize TypeScript config 2026-05-12 16:46:32 -04:00
Nymbo
9b61a78f19 Bump version to 0.1.2 2026-05-12 16:39:09 -04:00
Nymbo
6e551e51b5 Add plugin settings 2026-05-12 16:38:34 -04:00
Nymbo
86600490b1 Highlight copy while keys are held 2026-05-12 16:33:06 -04:00
11 changed files with 491 additions and 80 deletions

52
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,52 @@
name: Release
on:
push:
tags:
- "*.*.*"
permissions:
contents: write
id-token: write
attestations: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --force
- name: Build
run: npm run build
- name: Lint
run: npm run lint
- name: Generate artifact attestations
uses: actions/attest@v4
with:
subject-path: |
main.js
manifest.json
styles.css
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" main.js manifest.json styles.css \
--repo "${GITHUB_REPOSITORY}" \
--title "${GITHUB_REF_NAME}" \
--notes "Release ${GITHUB_REF_NAME}" \
--verify-tag

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ main.js
*.map
.obsidian/
.DS_Store
data.json

View file

@ -1,6 +1,36 @@
# Copy Flasher
# Copy Highlighter
Copy Flasher is an Obsidian plugin that briefly highlights copied text so you get visual confirmation that the copy action worked.
Copy Highlighter highlights text when you copy it in Obsidian, giving a clear visual confirmation that the copy action worked.
## Features
- Highlights copied editor text immediately when you press `Ctrl+C` or `Cmd+C`.
- Keeps the highlight visible while the copy shortcut is held.
- Supports copied text in reading view and other rendered Obsidian content.
- Includes settings for color, opacity, corner radius, rendered-text highlighting, and fallback timing.
- Works without external services, network requests, telemetry, or file-system access.
## Usage
Select text in Obsidian and copy it. The selected text is highlighted while the copy shortcut is held, then returns to normal when the keys are released.
For mouse or menu-based copy actions, Copy Highlighter uses a short configurable fallback duration because there is no keyboard release event to observe.
## Settings
Open **Settings -> Community plugins -> Copy Highlighter** to customize:
- Highlight color
- Editor opacity
- Rendered text opacity
- Corner radius
- Whether rendered Markdown text is highlighted
- Mouse/menu copy duration
- Lost-keyup fallback duration
## Privacy and permissions
Copy Highlighter runs entirely inside Obsidian. It does not collect data, send network requests, read your clipboard contents, or access files directly.
## Development
@ -22,19 +52,13 @@ Watch for changes during development:
npm run dev
```
For local testing, this repository can be linked into a development vault at:
```text
P:\Code Repos\Obs-Dev-Vault\Obs-Dev-Vault\.obsidian\plugins\copy-flasher
```
Obsidian loads community plugins from `.obsidian/plugins/<plugin-id>`. The folder name should match `manifest.json`'s `id`, and Obsidian expects the built `main.js`, `manifest.json`, and optional `styles.css` at that folder root.
After changing `manifest.json`, restart Obsidian so it re-reads the plugin metadata. After changing TypeScript, rebuild and reload the plugin, or use a hot-reload plugin in your development vault.
After changing `manifest.json`, restart Obsidian so it re-reads the plugin metadata. After changing TypeScript, rebuild and reload the plugin.
## Release Files
## Release files
Community plugin releases typically include:
Each GitHub release should include these assets:
- `main.js`
- `manifest.json`

View file

@ -8,6 +8,8 @@ export default tseslint.config(
languageOptions: {
globals: {
...globals.browser,
activeDocument: "readonly",
activeWindow: "readonly",
},
parserOptions: {
projectService: {

View file

@ -1,9 +1,9 @@
{
"id": "copy-flasher",
"name": "Copy Flasher",
"version": "0.1.0",
"minAppVersion": "0.15.0",
"description": "Briefly highlights text copied inside Obsidian.",
"id": "copy-highlighter",
"name": "Copy Highlighter",
"version": "0.2.4",
"minAppVersion": "1.0.0",
"description": "Highlights copied text.",
"author": "Nymbo",
"authorUrl": "https://github.com/Nymbo",
"isDesktopOnly": false

8
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "copy-flasher",
"version": "0.1.0",
"name": "copy-highlighter",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "copy-flasher",
"version": "0.1.0",
"name": "copy-highlighter",
"version": "0.2.4",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",

View file

@ -1,7 +1,7 @@
{
"name": "copy-flasher",
"version": "0.1.0",
"description": "Briefly highlights text copied inside Obsidian.",
"name": "copy-highlighter",
"version": "0.2.4",
"description": "Highlights copied text.",
"main": "main.js",
"type": "module",
"scripts": {

View file

@ -1,20 +1,43 @@
import { Plugin } from "obsidian";
import { App, Plugin, PluginSettingTab, Setting } from "obsidian";
import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
import { Decoration, DecorationSet, EditorView, ViewPlugin } from "@codemirror/view";
const FLASH_DURATION_MS = 650;
const LOST_KEYUP_FALLBACK_MS = 5000;
const NON_KEYBOARD_COPY_FALLBACK_MS = 650;
const MAX_DOM_FLASH_RECTS = 80;
interface CopyHighlighterSettings {
highlightColor: string;
editorOpacity: number;
renderedOpacity: number;
borderRadius: number;
enableRenderedMarkdownHighlight: boolean;
nonKeyboardCopyDurationMs: number;
lostKeyupFallbackMs: number;
}
type FlashRange = {
from: number;
to: number;
};
type ClearHighlight = () => void;
const DEFAULT_SETTINGS: CopyHighlighterSettings = {
highlightColor: "#ffd54f",
editorOpacity: 0.5,
renderedOpacity: 0.46,
borderRadius: 3,
enableRenderedMarkdownHighlight: true,
nonKeyboardCopyDurationMs: NON_KEYBOARD_COPY_FALLBACK_MS,
lostKeyupFallbackMs: LOST_KEYUP_FALLBACK_MS,
};
const addFlash = StateEffect.define<FlashRange[]>();
const clearFlash = StateEffect.define<void>();
const flashMark = Decoration.mark({
class: "copy-flasher-editor-highlight",
class: "copy-highlighter-editor-highlight",
});
const flashField = StateField.define<DecorationSet>({
@ -47,15 +70,103 @@ const flashField = StateField.define<DecorationSet>({
provide: (field) => EditorView.decorations.from(field),
});
function copyFlashExtension(durationMs: number) {
class HighlightSession {
private clearers = new Set<ClearHighlight>();
private ctrlOrMetaDown = false;
private cDown = false;
private fallbackTimer: number | null = null;
constructor(private getSettings: () => CopyHighlighterSettings) {}
register(clearHighlight: ClearHighlight) {
this.clearers.add(clearHighlight);
return () => {
this.clearers.delete(clearHighlight);
};
}
handleKeyDown(event: KeyboardEvent) {
if (event.key === "Control" || event.key === "Meta") {
this.ctrlOrMetaDown = true;
}
if (event.key.toLowerCase() === "c") {
this.cDown = true;
}
}
handleKeyUp(event: KeyboardEvent) {
if (event.key === "Control" || event.key === "Meta") {
this.ctrlOrMetaDown = false;
}
if (event.key.toLowerCase() === "c") {
this.cDown = false;
}
if (!this.isCopyChordHeld()) {
this.clear();
}
}
beginCopyHighlight() {
if (this.fallbackTimer !== null) {
window.clearTimeout(this.fallbackTimer);
}
const settings = this.getSettings();
const fallbackMs = this.isCopyChordHeld()
? settings.lostKeyupFallbackMs
: settings.nonKeyboardCopyDurationMs;
// This is only a cleanup guard. Normal Ctrl/Cmd+C clears on keyup.
this.fallbackTimer = window.setTimeout(() => {
this.clear();
}, fallbackMs);
}
clear() {
if (this.fallbackTimer !== null) {
window.clearTimeout(this.fallbackTimer);
this.fallbackTimer = null;
}
for (const clearHighlight of this.clearers) {
clearHighlight();
}
}
resetKeys() {
this.ctrlOrMetaDown = false;
this.cDown = false;
this.clear();
}
private isCopyChordHeld() {
return this.ctrlOrMetaDown && this.cDown;
}
}
function copyFlashExtension(highlightSession: HighlightSession) {
class CopyFlashView {
private clearTimer: number | null = null;
private unregister: (() => void) | null = null;
constructor(private view: EditorView) {}
registerClearer() {
if (this.unregister === null) {
this.unregister = highlightSession.register(() => {
this.view.dispatch({
effects: clearFlash.of(),
});
});
}
}
destroy() {
if (this.clearTimer !== null) {
window.clearTimeout(this.clearTimer);
if (this.unregister !== null) {
this.unregister();
}
}
@ -71,20 +182,11 @@ function copyFlashExtension(durationMs: number) {
return;
}
if (this.clearTimer !== null) {
window.clearTimeout(this.clearTimer);
}
this.registerClearer();
this.view.dispatch({
effects: addFlash.of(ranges),
});
this.clearTimer = window.setTimeout(() => {
this.view.dispatch({
effects: clearFlash.of(),
});
this.clearTimer = null;
}, durationMs);
highlightSession.beginCopyHighlight();
}
}
@ -106,18 +208,58 @@ function copyFlashExtension(durationMs: number) {
];
}
export default class CopyFlasherPlugin extends Plugin {
export default class CopyHighlighterPlugin extends Plugin {
settings: CopyHighlighterSettings = DEFAULT_SETTINGS;
private highlightSession = new HighlightSession(() => this.settings);
private domHighlights = new Set<HTMLElement>();
async onload() {
this.registerEditorExtension(copyFlashExtension(FLASH_DURATION_MS));
await this.loadSettings();
this.applyCssVariables();
this.addSettingTab(new CopyHighlighterSettingTab(this.app, this));
this.highlightSession.register(() => {
this.clearDomHighlights();
});
this.registerEditorExtension(copyFlashExtension(this.highlightSession));
this.registerDomEvent(activeDocument, "keydown", (event) => {
this.highlightSession.handleKeyDown(event);
}, true);
this.registerDomEvent(activeDocument, "keyup", (event) => {
this.highlightSession.handleKeyUp(event);
}, true);
this.registerDomEvent(activeWindow, "blur", () => {
this.highlightSession.resetKeys();
});
this.registerDomEvent(activeDocument, "visibilitychange", () => {
if (activeDocument.visibilityState === "hidden") {
this.highlightSession.resetKeys();
}
});
// CodeMirror handles editor selections. This fallback covers copied text in
// rendered Markdown or other Obsidian-owned DOM content.
this.registerDomEvent(document, "copy", (event) => {
this.registerDomEvent(activeDocument, "copy", (event) => {
this.flashDomSelection(event);
}, true);
}
onunload() {
this.clearDomHighlights();
activeDocument.body.style.removeProperty("--copy-highlighter-editor-background");
activeDocument.body.style.removeProperty("--copy-highlighter-border-radius");
}
private flashDomSelection(event: ClipboardEvent) {
if (!this.settings.enableRenderedMarkdownHighlight) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
@ -128,7 +270,7 @@ export default class CopyFlasherPlugin extends Plugin {
return;
}
const selection = window.getSelection();
const selection = target.ownerDocument.getSelection();
if (selection === null || selection.isCollapsed || selection.rangeCount === 0) {
return;
@ -147,6 +289,8 @@ export default class CopyFlasherPlugin extends Plugin {
for (let index = 0; index < selection.rangeCount; index += 1) {
this.flashRange(selection.getRangeAt(index));
}
this.highlightSession.beginCopyHighlight();
}
private flashRange(range: Range) {
@ -155,16 +299,215 @@ export default class CopyFlasherPlugin extends Plugin {
.slice(0, MAX_DOM_FLASH_RECTS);
for (const rect of rects) {
const overlay = document.createElement("div");
const ownerDocument = range.commonAncestorContainer.ownerDocument ?? activeDocument;
const ownerWindow = ownerDocument.defaultView ?? activeWindow;
const overlay = ownerDocument.createElement("div");
overlay.className = "copy-flasher-dom-highlight";
overlay.style.left = `${rect.left + window.scrollX}px`;
overlay.style.top = `${rect.top + window.scrollY}px`;
overlay.className = "copy-highlighter-dom-highlight";
overlay.style.left = `${rect.left + ownerWindow.scrollX}px`;
overlay.style.top = `${rect.top + ownerWindow.scrollY}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
overlay.style.backgroundColor = hexToRgba(
this.settings.highlightColor,
this.settings.renderedOpacity
);
overlay.style.borderRadius = `${this.settings.borderRadius}px`;
document.body.appendChild(overlay);
window.setTimeout(() => overlay.remove(), FLASH_DURATION_MS);
ownerDocument.body.appendChild(overlay);
this.domHighlights.add(overlay);
}
}
private clearDomHighlights() {
for (const highlight of this.domHighlights) {
highlight.remove();
}
this.domHighlights.clear();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<CopyHighlighterSettings>);
}
async saveSettings() {
await this.saveData(this.settings);
this.applyCssVariables();
}
resetSettings() {
this.settings = { ...DEFAULT_SETTINGS };
this.applyCssVariables();
}
private applyCssVariables() {
activeDocument.body.style.setProperty(
"--copy-highlighter-editor-background",
hexToRgba(this.settings.highlightColor, this.settings.editorOpacity)
);
activeDocument.body.style.setProperty(
"--copy-highlighter-border-radius",
`${this.settings.borderRadius}px`
);
}
}
class CopyHighlighterSettingTab extends PluginSettingTab {
constructor(app: App, private plugin: CopyHighlighterPlugin) {
super(app, plugin);
}
display() {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Appearance")
.setHeading();
new Setting(containerEl)
.setName("Highlight color")
.setDesc("The color used when copied text is highlighted.")
.addColorPicker((colorPicker) => {
colorPicker
.setValue(this.plugin.settings.highlightColor)
.onChange(async (value) => {
this.plugin.settings.highlightColor = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Editor opacity")
.setDesc("How strong the highlight appears in live editor text.")
.addSlider((slider) => {
slider
.setLimits(0.1, 1, 0.05)
.setValue(this.plugin.settings.editorOpacity)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.editorOpacity = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Rendered text opacity")
.setDesc("How strong the highlight appears outside the editor, such as reading view.")
.addSlider((slider) => {
slider
.setLimits(0.1, 1, 0.05)
.setValue(this.plugin.settings.renderedOpacity)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.renderedOpacity = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Corner radius")
.setDesc("Rounds the highlight corners.")
.addSlider((slider) => {
slider
.setLimits(0, 12, 1)
.setValue(this.plugin.settings.borderRadius)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.borderRadius = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Highlight rendered Markdown")
.setDesc("Also show copy confirmation when copying from reading view and other rendered text.")
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.enableRenderedMarkdownHighlight)
.onChange(async (value) => {
this.plugin.settings.enableRenderedMarkdownHighlight = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Mouse/menu copy duration")
.setDesc("How long the highlight stays visible for non-keyboard copy actions.")
.addText((text) => {
text
.setPlaceholder(String(DEFAULT_SETTINGS.nonKeyboardCopyDurationMs))
.setValue(String(this.plugin.settings.nonKeyboardCopyDurationMs))
.onChange(async (value) => {
this.plugin.settings.nonKeyboardCopyDurationMs = parsePositiveInt(
value,
DEFAULT_SETTINGS.nonKeyboardCopyDurationMs
);
await this.plugin.saveSettings();
});
text.inputEl.type = "number";
text.inputEl.min = "100";
text.inputEl.step = "50";
});
new Setting(containerEl)
.setName("Lost keyup fallback")
.setDesc("Cleanup delay if Obsidian does not receive the key release event.")
.addText((text) => {
text
.setPlaceholder(String(DEFAULT_SETTINGS.lostKeyupFallbackMs))
.setValue(String(this.plugin.settings.lostKeyupFallbackMs))
.onChange(async (value) => {
this.plugin.settings.lostKeyupFallbackMs = parsePositiveInt(
value,
DEFAULT_SETTINGS.lostKeyupFallbackMs
);
await this.plugin.saveSettings();
});
text.inputEl.type = "number";
text.inputEl.min = "500";
text.inputEl.step = "100";
});
new Setting(containerEl)
.setName("Reset settings")
.setDesc("Restore the default appearance and timing.")
.addButton((button) => {
button
.setButtonText("Restore defaults")
.onClick(async () => {
this.plugin.resetSettings();
await this.plugin.saveSettings();
this.display();
});
});
}
}
function hexToRgba(hex: string, opacity: number) {
const normalized = hex.replace("#", "");
const isValidHex = /^[0-9a-fA-F]{6}$/.test(normalized);
if (!isValidHex) {
return hexToRgba(DEFAULT_SETTINGS.highlightColor, opacity);
}
const red = parseInt(normalized.slice(0, 2), 16);
const green = parseInt(normalized.slice(2, 4), 16);
const blue = parseInt(normalized.slice(4, 6), 16);
const alpha = Math.max(0, Math.min(opacity, 1));
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function parsePositiveInt(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
return fallback;
}

View file

@ -1,32 +1,12 @@
.copy-flasher-editor-highlight {
background-color: rgba(255, 213, 79, 0.45);
border-radius: 3px;
animation: copy-flasher-editor-fade 650ms ease-out forwards;
.copy-highlighter-editor-highlight {
background-color: var(--copy-highlighter-editor-background, rgba(255, 213, 79, 0.5));
border-radius: var(--copy-highlighter-border-radius, 3px);
}
.copy-flasher-dom-highlight {
.copy-highlighter-dom-highlight {
position: absolute;
z-index: 9999;
pointer-events: none;
background-color: rgba(255, 213, 79, 0.42);
border-radius: 3px;
animation: copy-flasher-dom-fade 650ms ease-out forwards;
}
@keyframes copy-flasher-editor-fade {
0% {
background-color: rgba(255, 213, 79, 0.62);
}
100% {
background-color: rgba(255, 213, 79, 0);
}
}
@keyframes copy-flasher-dom-fade {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
background-color: rgba(255, 213, 79, 0.46);
border-radius: var(--copy-highlighter-border-radius, 3px);
}

View file

@ -1,6 +1,6 @@
{
"compilerOptions": {
"baseUrl": "src",
"strict": true,
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
@ -9,10 +9,11 @@
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"importHelpers": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"strictNullChecks": true,
"strictBindCallApply": true,
"allowSyntheticDefaultImports": true,

View file

@ -1,3 +1,11 @@
{
"0.1.0": "0.15.0"
"0.1.0": "0.15.0",
"0.1.1": "0.15.0",
"0.1.2": "0.16.0",
"0.1.3": "0.16.0",
"0.2.0": "0.16.0",
"0.2.1": "1.0.0",
"0.2.2": "1.0.0",
"0.2.3": "1.0.0",
"0.2.4": "1.0.0"
}