mirror of
https://github.com/lossless-group/image-gin.git
synced 2026-07-22 06:49:33 +00:00
Image Gin now intercepts every image dropped or pasted into a markdown note and asks where it should go: vault attachments, ImageKit, or Imgur. Built for writers who handle private client imagery and don't want a default-on third-party uploader silently shipping screenshots to a public CDN. Default is the vault. ImageKit is the recommended destination for less-public information that still wants a CDN. Imgur is there when something is genuinely shareable. Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste Confirmation Gate". Two policy modes: - always-confirm — modal on every image drop or paste - external-only — vault drops fall through silently; modal only appears when at least one external destination is enabled Per-session "remember choice" checkbox in the modal skips the gate for subsequent drops in the same note, resets on active-leaf-change, and never persists across Obsidian restarts. A reset command is registered under "Drop Gate: Reset session-remembered destination." Three destinations behind a small DropGateDestination interface: - VaultDestination — re-dispatches a synthetic event copy into Obsidian's internal view.currentMode.clipboardManager so the default attachment-handling code runs cleanly. Falls back to Vault.createBinary + FileManager.generateMarkdownLink if the internal surface ever disappears. - ImageKitDestination — thin adapter over the existing ImageKitService.uploadFile pipeline image-gin already uses for "Convert Local Images to Remote." Honors a drop-gate-specific folder setting (settings.dropGate.imageKitFolder) that overrides the main upload folder when set, so ad-hoc dropped images can be routed somewhere different from generated images. Inherits the main folder when blank. - ImgurDestination — anonymous client-ID upload to api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's requestUrl doesn't accept FormData. The modal is widened correctly (modalEl, not contentEl, per the perplexed widening reminder), shows all three destinations every time — configured ones selectable, un-configured ones greyed-out with a hint pointing at the right setting — and captures cursor position synchronously at drop-time, threading it through DropGateContext as insertPos so editor.replaceRange lands the markdown link where the user dropped, not wherever the cursor drifted while the modal was open. Conflict detection: if the third-party obsidian-imgur-plugin is also enabled, both plugins handle every drop and the user gets two modals back-to-back — preventDefault() blocks the browser default, not other plugins. We surface a persistent Notice on plugin load telling the user to disable the other one and configure Imgur from inside Image Gin. Bumps version to 0.2.0 (manifest.json, package.json, versions.json). Plan + design rationale: content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md Ship note: plugin-modules/image-gin/changelog/2026-05-09_01.md Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
No EOL
7.7 KiB
TypeScript
181 lines
No EOL
7.7 KiB
TypeScript
import { Plugin, Notice } from 'obsidian';
|
|
// Import modal classes
|
|
import { CurrentFileModal } from './src/modals/CurrentFileModal';
|
|
import { ConvertLocalImagesForCurrentFile } from './src/modals/ConvertLocalImagesForCurrentFile';
|
|
import { BatchDirectoryConvertLocalToRemote } from './src/modals/BatchDirectoryConvertLocalToRemote';
|
|
import { MagnificModal } from './src/modals/MagnificModal';
|
|
import { IdeogramModal } from './src/modals/IdeogramModal';
|
|
import type { ImageGinSettings} from './src/settings/settings';
|
|
import { ImageGinSettingTab, DEFAULT_SETTINGS } from './src/settings/settings';
|
|
import { logger } from './src/utils/logger';
|
|
import { DropGateHandlers } from './src/handlers/DropGateHandlers';
|
|
import type { DropGateDestination } from './src/destinations/types';
|
|
import { VaultDestination } from './src/destinations/VaultDestination';
|
|
import { ImageKitDestination } from './src/destinations/ImageKitDestination';
|
|
import { ImgurDestination } from './src/destinations/ImgurDestination';
|
|
|
|
// Deep-merge `loaded` over `base` for plain objects only. Arrays are
|
|
// replaced wholesale (so user-edited `imageSizes` overrides defaults
|
|
// rather than being elementwise-merged). Required so partial nested
|
|
// provider blocks in data.json (e.g. `ideogram: { enabled, apiKey }`)
|
|
// don't drop the rest of the provider's defaults.
|
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
}
|
|
|
|
function deepMergeSettings<T>(base: T, loaded: unknown): T {
|
|
if (!isPlainObject(loaded) || !isPlainObject(base)) {
|
|
return (loaded === undefined ? base : loaded) as T;
|
|
}
|
|
const out: Record<string, unknown> = { ...base };
|
|
for (const key of Object.keys(loaded)) {
|
|
const baseVal = (base as Record<string, unknown>)[key];
|
|
const loadedVal = loaded[key];
|
|
if (isPlainObject(baseVal) && isPlainObject(loadedVal)) {
|
|
out[key] = deepMergeSettings(baseVal, loadedVal);
|
|
} else {
|
|
out[key] = loadedVal;
|
|
}
|
|
}
|
|
return out as T;
|
|
}
|
|
|
|
export default class ImageGinPlugin extends Plugin {
|
|
settings: ImageGinSettings = { ...DEFAULT_SETTINGS };
|
|
private dropGateHandlers: DropGateHandlers | null = null;
|
|
|
|
async loadSettings(): Promise<void> {
|
|
const loadedSettings = (await this.loadData()) ?? {};
|
|
// One-shot migration: Freepik rebranded to Magnific. Move legacy
|
|
// `freepik` config into `magnific` so the user's saved key/enabled
|
|
// flag survive the schema rename. Persists via saveSettings below.
|
|
if (loadedSettings.freepik && !loadedSettings.magnific) {
|
|
loadedSettings.magnific = loadedSettings.freepik;
|
|
delete loadedSettings.freepik;
|
|
}
|
|
this.settings = deepMergeSettings(DEFAULT_SETTINGS, loadedSettings);
|
|
await this.saveSettings();
|
|
}
|
|
|
|
async saveSettings(): Promise<void> {
|
|
await this.saveData(this.settings);
|
|
}
|
|
|
|
async onload(): Promise<void> {
|
|
await this.loadSettings();
|
|
|
|
// Wire up the file logger so console.* calls in services/modals
|
|
// also persist to .obsidian/plugins/image-gin/log.json.
|
|
logger.initialize(this.app.vault);
|
|
|
|
this.addSettingTab(new ImageGinSettingTab(this.app, this));
|
|
|
|
// Register commands directly in onload
|
|
this.addCommand({
|
|
id: 'generate-images-for-current-file',
|
|
name: 'Generate Images for Current File',
|
|
callback: () => {
|
|
new CurrentFileModal(this.app, this).open();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'convert-local-images-to-remote',
|
|
name: 'Convert Local Images to Remote Images',
|
|
callback: () => {
|
|
new ConvertLocalImagesForCurrentFile(this.app, this).open();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'batch-convert-directory-images',
|
|
name: 'Batch Convert Directory Images to Remote',
|
|
callback: () => {
|
|
new BatchDirectoryConvertLocalToRemote(this.app, this).open();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'search-magnific-images',
|
|
name: 'Search Magnific Images',
|
|
callback: () => {
|
|
if (this.settings.magnific.enabled) {
|
|
new MagnificModal(this.app, this).open();
|
|
} else {
|
|
new Notice('Magnific integration is not enabled. Please enable it in settings.');
|
|
}
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'generate-images-ideogram',
|
|
name: 'Generate Images (Ideogram)',
|
|
callback: () => {
|
|
if (this.settings.ideogram.enabled) {
|
|
new IdeogramModal(this.app, this).open();
|
|
} else {
|
|
new Notice('Ideogram integration is not enabled. Please enable it in settings.');
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── Drop / paste confirmation gate ────────────────────────
|
|
// Intercepts every image dropped or pasted into a markdown view and
|
|
// pops a destination-picker modal before anything reaches disk or
|
|
// the network. See src/handlers/DropGateHandlers.ts.
|
|
const destinations: DropGateDestination[] = [
|
|
new VaultDestination(this.app),
|
|
new ImageKitDestination(() => this.settings),
|
|
new ImgurDestination(() => this.settings),
|
|
];
|
|
this.dropGateHandlers = new DropGateHandlers(this, destinations);
|
|
|
|
this.registerEvent(this.app.workspace.on('editor-drop', this.dropGateHandlers.onDrop));
|
|
this.registerEvent(this.app.workspace.on('editor-paste', this.dropGateHandlers.onPaste));
|
|
this.registerEvent(
|
|
this.app.workspace.on('active-leaf-change', () => this.dropGateHandlers?.resetSession())
|
|
);
|
|
|
|
// Detect a conflicting third-party imgur plugin. Both plugins register
|
|
// editor-drop handlers and Obsidian fires every registered handler in
|
|
// turn — preventDefault() only blocks the browser default, not other
|
|
// plugins. So if obsidian-imgur-plugin is enabled, the user gets two
|
|
// modals: ours, then theirs. Warn once.
|
|
if (this.settings.dropGate.enabled) {
|
|
this.warnAboutConflictingImgurPlugin();
|
|
}
|
|
|
|
this.addCommand({
|
|
id: 'image-gin-reset-drop-gate-session',
|
|
name: 'Drop Gate: Reset session-remembered destination',
|
|
callback: () => {
|
|
this.dropGateHandlers?.resetSession();
|
|
new Notice('Image Gin: drop-gate session reset.');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* If obsidian-imgur-plugin is enabled, both plugins will fire on every
|
|
* image drop and the user gets two modals back-to-back. Surface a one-
|
|
* time, persistent Notice telling them to disable the other.
|
|
*/
|
|
private warnAboutConflictingImgurPlugin(): void {
|
|
const pluginsApi = (this.app as unknown as {
|
|
plugins?: { enabledPlugins?: Set<string>; manifests?: Record<string, unknown> };
|
|
}).plugins;
|
|
|
|
const enabled = pluginsApi?.enabledPlugins;
|
|
if (!enabled) return;
|
|
if (!enabled.has('obsidian-imgur-plugin')) return;
|
|
|
|
new Notice(
|
|
'Image Gin Drop Gate: the "Imgur" community plugin is also enabled. ' +
|
|
'Both plugins handle image drops, so you will see two modals. ' +
|
|
'Disable the Imgur community plugin in Settings → Community Plugins, ' +
|
|
'then enable Image Gin\'s Imgur destination if you want Imgur uploads from inside the gate.',
|
|
0
|
|
);
|
|
console.warn('[image-gin/drop-gate] obsidian-imgur-plugin is enabled — drop events will fire twice.');
|
|
}
|
|
} |