Initial Obsidian plugin scaffold

This commit is contained in:
Nymbo 2026-05-12 16:22:48 -04:00
commit d6893b22ee
14 changed files with 5611 additions and 0 deletions

9
.editorconfig Normal file
View 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
View file

@ -0,0 +1,5 @@
node_modules/
main.js
*.map
.obsidian/
.DS_Store

21
LICENSE Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

36
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"0.1.0": "0.15.0"
}