mirror of
https://github.com/nymbo/Copy-Highlighter.git
synced 2026-07-22 06:56:52 +00:00
Initial Obsidian plugin scaffold
This commit is contained in:
commit
d6893b22ee
14 changed files with 5611 additions and 0 deletions
9
.editorconfig
Normal file
9
.editorconfig
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
main.js
|
||||
*.map
|
||||
.obsidian/
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Nymbo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
43
README.md
Normal file
43
README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Copy Flasher
|
||||
|
||||
Copy Flasher is an Obsidian plugin that briefly highlights copied text so you get visual confirmation that the copy action worked.
|
||||
|
||||
## Development
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Build once:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Watch for changes during development:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
## Release Files
|
||||
|
||||
Community plugin releases typically include:
|
||||
|
||||
- `main.js`
|
||||
- `manifest.json`
|
||||
- `styles.css`
|
||||
|
||||
The source TypeScript is for development; Obsidian loads the bundled `main.js`.
|
||||
50
esbuild.config.mjs
Normal file
50
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import { builtinModules } from "node:module";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD.
|
||||
If you want to view the source, visit the GitHub repository for this plugin.
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtinModules,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
33
eslint.config.mts
Normal file
33
eslint.config.mts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import tseslint from "typescript-eslint";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
"eslint.config.mts",
|
||||
"manifest.json",
|
||||
],
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
extraFileExtensions: [".json"],
|
||||
},
|
||||
},
|
||||
},
|
||||
...obsidianmd.configs.recommended,
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"esbuild.config.mjs",
|
||||
"version-bump.mjs",
|
||||
"versions.json",
|
||||
"main.js",
|
||||
])
|
||||
);
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "copy-flasher",
|
||||
"name": "Copy Flasher",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Briefly highlights text copied inside Obsidian.",
|
||||
"author": "Nymbo",
|
||||
"authorUrl": "https://github.com/Nymbo",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
5153
package-lock.json
generated
Normal file
5153
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
package.json
Normal file
36
package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "copy-flasher",
|
||||
"version": "0.1.0",
|
||||
"description": "Briefly highlights text copied inside Obsidian.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"keywords": [
|
||||
"obsidian",
|
||||
"obsidian-plugin",
|
||||
"clipboard"
|
||||
],
|
||||
"author": "Nymbo",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.30.1",
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint-plugin-obsidianmd": "0.1.9",
|
||||
"globals": "14.0.0",
|
||||
"jiti": "2.6.1",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.42.1",
|
||||
"obsidian": "latest"
|
||||
}
|
||||
}
|
||||
170
src/main.ts
Normal file
170
src/main.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { Plugin } from "obsidian";
|
||||
import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
|
||||
import { Decoration, DecorationSet, EditorView, ViewPlugin } from "@codemirror/view";
|
||||
|
||||
const FLASH_DURATION_MS = 650;
|
||||
const MAX_DOM_FLASH_RECTS = 80;
|
||||
|
||||
type FlashRange = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
const addFlash = StateEffect.define<FlashRange[]>();
|
||||
const clearFlash = StateEffect.define<void>();
|
||||
|
||||
const flashMark = Decoration.mark({
|
||||
class: "copy-flasher-editor-highlight",
|
||||
});
|
||||
|
||||
const flashField = StateField.define<DecorationSet>({
|
||||
create() {
|
||||
return Decoration.none;
|
||||
},
|
||||
update(decorations, transaction) {
|
||||
decorations = decorations.map(transaction.changes);
|
||||
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(clearFlash)) {
|
||||
decorations = Decoration.none;
|
||||
}
|
||||
|
||||
if (effect.is(addFlash)) {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
|
||||
for (const range of effect.value) {
|
||||
if (range.from < range.to) {
|
||||
builder.add(range.from, range.to, flashMark);
|
||||
}
|
||||
}
|
||||
|
||||
decorations = builder.finish();
|
||||
}
|
||||
}
|
||||
|
||||
return decorations;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
|
||||
function copyFlashExtension(durationMs: number) {
|
||||
class CopyFlashView {
|
||||
private clearTimer: number | null = null;
|
||||
|
||||
constructor(private view: EditorView) {}
|
||||
|
||||
destroy() {
|
||||
if (this.clearTimer !== null) {
|
||||
window.clearTimeout(this.clearTimer);
|
||||
}
|
||||
}
|
||||
|
||||
flashCopiedSelection() {
|
||||
const ranges = this.view.state.selection.ranges
|
||||
.filter((range) => !range.empty)
|
||||
.map((range) => ({
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
}));
|
||||
|
||||
if (ranges.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.clearTimer !== null) {
|
||||
window.clearTimeout(this.clearTimer);
|
||||
}
|
||||
|
||||
this.view.dispatch({
|
||||
effects: addFlash.of(ranges),
|
||||
});
|
||||
|
||||
this.clearTimer = window.setTimeout(() => {
|
||||
this.view.dispatch({
|
||||
effects: clearFlash.of(),
|
||||
});
|
||||
this.clearTimer = null;
|
||||
}, durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
const copyFlashViewPlugin = ViewPlugin.define(
|
||||
(view) => new CopyFlashView(view),
|
||||
{
|
||||
eventHandlers: {
|
||||
copy(_event, view) {
|
||||
const plugin = view.plugin(copyFlashViewPlugin);
|
||||
plugin?.flashCopiedSelection();
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return [
|
||||
flashField,
|
||||
copyFlashViewPlugin,
|
||||
];
|
||||
}
|
||||
|
||||
export default class CopyFlasherPlugin extends Plugin {
|
||||
async onload() {
|
||||
this.registerEditorExtension(copyFlashExtension(FLASH_DURATION_MS));
|
||||
|
||||
// CodeMirror handles editor selections. This fallback covers copied text in
|
||||
// rendered Markdown or other Obsidian-owned DOM content.
|
||||
this.registerDomEvent(document, "copy", (event) => {
|
||||
this.flashDomSelection(event);
|
||||
}, true);
|
||||
}
|
||||
|
||||
private flashDomSelection(event: ClipboardEvent) {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest(".cm-editor") !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection === null || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRoot = this.app.workspace.containerEl;
|
||||
|
||||
if (selection.anchorNode === null || selection.focusNode === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeRoot.contains(selection.anchorNode) || !activeRoot.contains(selection.focusNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < selection.rangeCount; index += 1) {
|
||||
this.flashRange(selection.getRangeAt(index));
|
||||
}
|
||||
}
|
||||
|
||||
private flashRange(range: Range) {
|
||||
const rects = Array.from(range.getClientRects())
|
||||
.filter((rect) => rect.width > 0 && rect.height > 0)
|
||||
.slice(0, MAX_DOM_FLASH_RECTS);
|
||||
|
||||
for (const rect of rects) {
|
||||
const overlay = document.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.style.width = `${rect.width}px`;
|
||||
overlay.style.height = `${rect.height}px`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
window.setTimeout(() => overlay.remove(), FLASH_DURATION_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
styles.css
Normal file
32
styles.css
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.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-flasher-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;
|
||||
}
|
||||
}
|
||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strictBindCallApply": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
16
version-bump.mjs
Normal file
16
version-bump.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
if (!targetVersion) {
|
||||
throw new Error("No package version found.");
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
|
||||
manifest.version = targetVersion;
|
||||
versions[targetVersion] = manifest.minAppVersion;
|
||||
|
||||
writeFileSync("manifest.json", `${JSON.stringify(manifest, null, "\t")}\n`);
|
||||
writeFileSync("versions.json", `${JSON.stringify(versions, null, "\t")}\n`);
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue