feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0

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>
This commit is contained in:
mpstaton 2026-05-09 01:20:36 -05:00
parent 91922ccf80
commit b6e7d7f6ed
19 changed files with 6408 additions and 18 deletions

155
changelog/2026-05-09_01.md Normal file
View file

@ -0,0 +1,155 @@
---
date_created: 2026-05-09
date_modified: 2026-05-09
title: "Drop Gate — every image asks where it should go"
lede: "Image Gin now intercepts every image you drag or paste into a note and pops a single confirmation modal: vault, 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 for less-public material that still wants a CDN. Imgur is there when something is genuinely shareable."
publish: true
authors:
- Michael Staton
augmented_with:
- Claude Code on Claude Opus 4.7 (1M context)
files_changed:
- main.ts
- src/handlers/DropGateHandlers.ts
- src/modals/DropGateModal.ts
- src/destinations/types.ts
- src/destinations/VaultDestination.ts
- src/destinations/ImageKitDestination.ts
- src/destinations/ImgurDestination.ts
- src/utils/dropGateEvents.ts
- src/settings/settings.ts
- src/styles/drop-gate.css
- src/styles/current-file-modal.css
---
## Why Care?
We use Obsidian to write about companies. The artifacts those companies share with us — growth charts, dashboard screenshots, photos of a whiteboard — are not always meant to leave a private context. The default failure mode used to be silent: drag a file in, and the bytes go where Obsidian (or some default-on third-party uploader plugin) decides without asking. That's fine for a recipe. It's a phone call we don't want to make for anything client-sensitive.
The Drop Gate puts a deliberate decision between the drop and the destination. One modal, three destinations:
- **Vault attachments** — Obsidian's standard private behavior. Bytes stay in the vault.
- **ImageKit** — your own private CDN. We use it when material isn't strictly secret but we don't want to host it forever ourselves. *This is our default for less-public information.*
- **Imgur** — anonymous public upload. Free, fast, durable. Use when something is genuinely shareable.
Off by default. Toggle it on in **Settings → Image Gin → Drag-Drop / Paste Confirmation Gate**.
## What's New?
A new feature inside Image Gin, layered alongside the existing Recraft / Magnific / Ideogram / ImageKit pipelines. No new plugin to install — same plugin, a new section in settings.
- **Intercepts every image drop and paste** in a markdown view via Obsidian's `editor-drop` and `editor-paste` workspace events.
- **The modal** shows the dropped file(s) (name, size, type) and a radio group of all three destinations. Configured destinations are selectable; un-configured ones are visible-but-disabled with a hint pointing at the right setting. You can see what's possible even before you've turned anything on.
- **Three destinations behind a small `DropGateDestination` interface** so a fourth (S3, Bunny, Cloudinary, an internal SharePoint of doom) is a class plus a settings panel:
- **VaultDestination** — re-dispatches a synthetic event copy into Obsidian's internal `clipboardManager` so the default attachment-handling code runs cleanly. Falls back to `Vault.createBinary` + `FileManager.generateMarkdownLink` if `clipboardManager` is unavailable.
- **ImageKitDestination** — wraps Image Gin's existing `ImageKitService`. Same multipart-upload pipeline that already powers "Convert Local Images to Remote." A drop-gate-specific folder setting (`dropGate.imageKitFolder`) overrides the main upload folder when set, so ad-hoc dropped images can land somewhere different from generated images.
- **ImgurDestination** — anonymous client-ID upload to `api.imgur.com/3/image`. Free, no account.
- **Two policy modes**: `always-confirm` (modal on every image, the default) and `external-only` (vault drops fall through silently; modal only appears if at least one external destination is enabled).
- **Per-session "remember choice"** checkbox in the modal — skips the modal for subsequent drops in the current note. Resets on `active-leaf-change`. Never persists across Obsidian restarts.
- **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.
- **Reset command** in the palette: *"Drop Gate: Reset session-remembered destination."*
## How it feels
Drop a screenshot into a note. Modal opens, wide enough to read (~720px). Three destinations visible — the ones you've configured are selectable; the ones you haven't carry a one-line nudge to settings. Pick ImageKit. Modal closes. Image uploads. Markdown link lands at your cursor. Cursor moves to the next line. Editor refocuses. Console says `[image-gin/drop-gate] ImageKit upload → https://ik.imagekit.io/...`. You keep typing.
If you'd rather not be asked every time, set the policy to *external-only* and your vault drops happen silently — only ImageKit / Imgur destinations bring up the modal.
## The arc
We started with the third-party `obsidian-imgur-plugin` by Kirill Gavrilov. Its drag-confirm pattern is the right idea but biased the wrong way for our workflow: it asks "should we upload to Imgur or paste locally?" The answer for client-sensitive imagery is "neither, until you tell me where." We wanted vault-first, third-party host second, and only with deliberate confirmation each time.
We sketched a standalone plugin, then folded it back into Image Gin once it was clear that:
- Image Gin already owns the ImageKit upload pipeline (the most useful private destination).
- The `DropGateDestination` interface is small enough that it doesn't justify its own plugin; it sits naturally next to the existing Recraft / Magnific / Ideogram services.
- One settings tab is easier than two.
So this is a feature add inside Image Gin, not a new sibling plugin. The `ImageKitDestination` is a thin adapter over `ImageKitService.uploadFile()` — no duplicated upload logic.
## Architecture
```
src/
├── handlers/DropGateHandlers.ts editor-drop + editor-paste; re-entry guard;
│ synchronous preventDefault; cursor capture
├── modals/DropGateModal.ts deferred-promise modal; modalEl-attached
│ for proper widening; renders all destinations
│ with disabled-state for un-configured ones
├── destinations/
│ ├── types.ts DropGateDestination + DropGateContext
│ │ (carries the captured insertPos)
│ ├── VaultDestination.ts clipboardManager re-dispatch + explicit-API fallback
│ ├── ImageKitDestination.ts wraps existing ImageKitService.uploadFile
│ └── ImgurDestination.ts anonymous client-ID upload
├── utils/dropGateEvents.ts DragEventCopy, PasteEventCopy (re-entry guards),
│ allFilesAreImages, fileNameFor
├── settings/settings.ts +dropGate +imgur blocks; settings-tab UI
└── styles/drop-gate.css modal styles, scoped to .image-gin-drop-gate-modal
```
`main.ts` grew by ~40 lines: instantiate three destinations, wire two `workspace.on(...)` calls, register a reset command, run the conflict check.
## Load-bearing details
Three implementation details we expect to forget and want findable when we do:
```ts
// 1. Re-entry guard. Without this, our own VaultDestination's
// re-dispatch loops back through the handler. Infinite loop.
if (e instanceof DragEventCopy) return
// 2. Synchronous preventDefault — must come BEFORE any await,
// otherwise the browser already gave up on cancelling the default.
e.preventDefault()
// 3. Capture cursor synchronously, before awaiting the modal.
// By the time the user picks a destination, editor.getCursor()
// is unreliable — focus may have drifted. We pass insertPos
// through DropGateContext and use editor.replaceRange(md, pos)
// instead of editor.replaceSelection(md).
const insertPos = editor.getCursor()
```
The vault fall-through uses an undocumented internal: `view.currentMode.clipboardManager.handleDrop(DragEventCopy.create(e, files))`. It's not in `obsidian.d.ts` but stable in practice; we cast through `unknown` and fall back to `Vault.createBinary` + `FileManager.generateMarkdownLink` if the surface ever disappears.
The `editor-drop` callback signature in `obsidian.d.ts` is `(evt: DragEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo) => any`. `MarkdownFileInfo` is the canvas / non-markdown variant; we narrow with `info instanceof MarkdownView` and bail on canvas drops. Canvas support is a follow-up.
## Settings surface
Two new sections in the Image Gin settings tab, both default-off:
| Section | Setting | Default |
|------------------|------------------------------------------|------------------------|
| Drop Gate | Enable drop gate | Off |
| Drop Gate | Policy mode | Always confirm |
| Drop Gate | Default destination | Vault attachments |
| Drop Gate | Show "remember for session" checkbox | On |
| Drop Gate | ImageKit folder for drop-gate uploads | empty (inherits main) |
| Imgur | Enable Imgur destination | Off |
| Imgur | Imgur client ID | empty |
ImageKit's main settings (private key, endpoint, main upload folder) live in their existing section above — they pre-date this feature. The drop-gate uses them whenever `imageKit.enabled` is on.
## What to test
1. Toggle Drop Gate on. Drop a PNG into a markdown note → modal appears with all three destinations. Vault is pre-selected. Click Insert → image lands in vault attachments, markdown link inserted at cursor.
2. Configure ImageKit and drop again → ImageKit row becomes selectable. Pick it → image uploads, markdown link with the ImageKit URL inserted.
3. Configure Imgur (anonymous client ID) and drop again → Imgur row becomes selectable.
4. Tick "Remember for this session," drop a second image in the same note → no modal, goes straight to your remembered destination.
5. Switch to a different note → modal returns on next drop.
6. Drop a non-image file → falls through to Obsidian's default behavior; modal does not appear.
7. With the `obsidian-imgur-plugin` community plugin also enabled → you'll see a persistent Notice on Image Gin's load telling you to disable it. Disable it, reload, drops go through Image Gin only.
## Reference
- Plan: `content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md` — design rationale, threat model, phasing.
- Inspiration / interception pattern: [`gavvvr/obsidian-imgur-plugin`](https://github.com/gavvvr/obsidian-imgur-plugin) (MIT). Disable it if you enable Image Gin's Drop Gate; both register `editor-drop` and you'll see two modals otherwise.
- Modal widening mechanic: `perplexed/context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md``modalEl` vs. `contentEl`. The 720px width and 88vh height only take effect because the class is on `modalEl`.
- ImageKit upload pipeline: existing `src/services/imagekitService.ts`, reused via the `ImageKitDestination` adapter.
## Next
- Canvas support — `MarkdownFileInfo` rather than `MarkdownView`; needs a parallel handler.
- Optional `imagery_policy:` frontmatter gate ("always confirm for this folder/file"), once we see whether always-on is friction-light enough not to need it.
- Filename / preview thumbnail in the modal — currently shows name + size + type only.

5043
log.json

File diff suppressed because it is too large Load diff

65
main.ts
View file

@ -8,6 +8,11 @@ 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
@ -37,6 +42,7 @@ function deepMergeSettings<T>(base: T, loaded: unknown): 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()) ?? {};
@ -112,5 +118,64 @@ export default class ImageGinPlugin extends Plugin {
}
}
});
// ─── 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.');
}
}

View file

@ -1,7 +1,7 @@
{
"id": "image-gin",
"name": "Image Gin",
"version": "0.1.1",
"version": "0.2.0",
"minAppVersion": "1.8.10",
"description": "Generate AI images, search stock images, and upload to ImageKit CDN.",
"author": "The Lossless Group",

View file

@ -1,6 +1,6 @@
{
"name": "image-gin",
"version": "0.1.1",
"version": "0.2.0",
"description": "Generate AI images, search stock images, and upload to ImageKit CDN.",
"main": "main.js",
"scripts": {

View file

@ -1 +1,4 @@
packages: []
onlyBuiltDependencies:
- esbuild

View file

@ -0,0 +1,72 @@
import { Notice } from 'obsidian';
import type { DropGateContext, DropGateDestination } from './types';
import type { ImageGinSettings } from '../settings/settings';
import { ImageKitService } from '../services/imagekitService';
import { fileNameFor } from '../utils/dropGateEvents';
/**
* Wraps the existing ImageKitService the same multipart-upload pipeline
* image-gin already uses for its "Convert Local Images to Remote" command.
*
* Folder resolution: drop-gate-specific folder override
* (settings.dropGate.imageKitFolder) takes precedence; falls back to the
* main settings.imageKit.uploadFolder when blank. Both come straight from
* the user's settings never hardcoded.
*/
export class ImageKitDestination implements DropGateDestination {
readonly id = 'imagekit' as const;
readonly label = 'ImageKit (private CDN)';
constructor(private readonly getSettings: () => ImageGinSettings) {}
get description(): string {
const folder = this.resolveFolder();
return folder
? `Upload to ImageKit folder "${folder}". Private host.`
: 'Upload to ImageKit. Private host.';
}
isAvailable(): boolean {
const s = this.getSettings().imageKit;
return s.enabled && s.privateKey.length > 0 && s.uploadEndpoint.length > 0;
}
async insert(files: readonly File[], ctx: DropGateContext): Promise<void> {
const settings = this.getSettings();
const service = new ImageKitService(settings);
const folder = this.resolveFolder();
let pos = ctx.insertPos;
for (const file of files) {
const buffer = await file.arrayBuffer();
const result = await service.uploadFile(buffer, fileNameFor(file), folder);
if (!result.url) {
console.error('[image-gin/drop-gate] ImageKit upload returned no url', result);
throw new Error('ImageKit returned an empty URL — check console for the raw response.');
}
console.log(`[image-gin/drop-gate] ImageKit upload → ${result.url}`);
const alt = file.name || 'image';
const md = `![${alt}](${result.url})\n`;
ctx.editor.replaceRange(md, pos);
// Advance position past what we just inserted so multi-file
// batches stack instead of overwriting each other.
pos = { line: pos.line + 1, ch: 0 };
}
// Move cursor to after the last inserted line and focus the editor.
ctx.editor.setCursor(pos);
ctx.editor.focus();
new Notice(
`Image Gin: uploaded ${files.length} image${files.length === 1 ? '' : 's'} to ImageKit${folder ? ` (${folder})` : ''}.`
);
}
private resolveFolder(): string {
const s = this.getSettings();
const override = s.dropGate.imageKitFolder.trim();
if (override) return override;
return s.imageKit.uploadFolder.trim();
}
}

View file

@ -0,0 +1,87 @@
import { Notice, requestUrl } from 'obsidian';
import type { DropGateContext, DropGateDestination } from './types';
import type { ImageGinSettings } from '../settings/settings';
const IMGUR_ENDPOINT = 'https://api.imgur.com/3/image';
interface ImgurResponse {
data?: { link?: string; deletehash?: string };
success?: boolean;
}
/**
* Anonymous Imgur uploader. Use for non-sensitive imagery where a free
* public CDN is fine and we don't need an account.
*/
export class ImgurDestination implements DropGateDestination {
readonly id = 'imgur' as const;
readonly label = 'Imgur (public CDN)';
readonly description = 'Anonymous upload to imgur.com. Public. Use for non-sensitive imagery.';
constructor(private readonly getSettings: () => ImageGinSettings) {}
isAvailable(): boolean {
const s = this.getSettings().imgur;
return s.enabled && s.clientId.length > 0;
}
async insert(files: readonly File[], ctx: DropGateContext): Promise<void> {
const { clientId } = this.getSettings().imgur;
let pos = ctx.insertPos;
for (const file of files) {
const link = await this.upload(file, clientId);
console.log(`[image-gin/drop-gate] Imgur upload → ${link}`);
const alt = file.name || 'image';
const md = `![${alt}](${link})\n`;
ctx.editor.replaceRange(md, pos);
pos = { line: pos.line + 1, ch: 0 };
}
ctx.editor.setCursor(pos);
ctx.editor.focus();
new Notice(`Image Gin: uploaded ${files.length} image${files.length === 1 ? '' : 's'} to Imgur.`);
}
private async upload(file: File, clientId: string): Promise<string> {
const boundary = '----image-gin-imgur-' + Math.random().toString(36).slice(2);
const lines: string[] = [];
lines.push(`--${boundary}`);
lines.push(`Content-Disposition: form-data; name="image"; filename="${file.name || 'image'}"`);
lines.push(`Content-Type: ${file.type || 'application/octet-stream'}`);
lines.push('');
const header = lines.join('\r\n') + '\r\n';
const footer = `\r\n--${boundary}--\r\n`;
const headerBytes = new TextEncoder().encode(header);
const footerBytes = new TextEncoder().encode(footer);
const fileBytes = new Uint8Array(await file.arrayBuffer());
const totalLen = headerBytes.length + fileBytes.length + footerBytes.length;
const body = new Uint8Array(totalLen);
body.set(headerBytes, 0);
body.set(fileBytes, headerBytes.length);
body.set(footerBytes, headerBytes.length + fileBytes.length);
const response = await requestUrl({
url: IMGUR_ENDPOINT,
method: 'POST',
headers: {
Authorization: `Client-ID ${clientId}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body: body.buffer,
throw: false,
});
if (response.status !== 200) {
throw new Error(`Imgur upload failed (${response.status}): ${response.text}`);
}
const data: ImgurResponse = (typeof response.json === 'function' ? await response.json() : response.json) as ImgurResponse;
const link = data.data?.link;
if (!link) {
throw new Error('Imgur response missing link.');
}
return link;
}
}

View file

@ -0,0 +1,110 @@
import type { App, TFile } from 'obsidian';
import { Notice } from 'obsidian';
import type { DropGateContext, DropGateDestination } from './types';
import { DragEventCopy, PasteEventCopy, fileNameFor } from '../utils/dropGateEvents';
/**
* Internal-clipboard-manager surface undocumented in obsidian.d.ts but
* stable in practice. If `clipboardManager` is missing at runtime we fall
* back to the explicit Vault.createBinary + FileManager.generateMarkdownLink
* path.
*/
interface InternalClipboardManager {
handleDrop(e: DragEvent): void;
handlePaste(e: ClipboardEvent): void;
}
interface ViewModeWithClipboard {
clipboardManager?: InternalClipboardManager;
}
export class VaultDestination implements DropGateDestination {
readonly id = 'vault' as const;
readonly label = 'Vault attachments';
readonly description = 'Save into the vault. Default Obsidian behavior. Private.';
constructor(private readonly app: App) {}
isAvailable(): boolean {
return true;
}
async insert(files: readonly File[], ctx: DropGateContext): Promise<void> {
const view = ctx.view;
const mode = view.currentMode as unknown as ViewModeWithClipboard;
const clip = mode.clipboardManager;
if (clip) {
try {
if (ctx.originalEvent instanceof DragEvent) {
clip.handleDrop(DragEventCopy.create(ctx.originalEvent, files));
} else {
clip.handlePaste(PasteEventCopy.create(ctx.originalEvent, files));
}
return;
} catch (err) {
console.error('[image-gin/drop-gate] clipboardManager re-dispatch failed; falling back', err);
// fall through to explicit-API path
}
}
await this.fallbackInsert(files, ctx);
}
/**
* Explicit-API fallback if Obsidian's internal clipboardManager surface
* disappears. Slower (no built-in attachment-folder polish) but type-safe.
*/
private async fallbackInsert(files: readonly File[], ctx: DropGateContext): Promise<void> {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
new Notice('Image Gin: no active file for vault drop.');
return;
}
const attachmentDir = this.attachmentDirFor(activeFile);
await this.ensureDir(attachmentDir);
let pos = ctx.insertPos;
for (const file of files) {
const safeName = await this.uniqueFileName(attachmentDir, fileNameFor(file));
const buffer = await file.arrayBuffer();
const path = attachmentDir ? `${attachmentDir}/${safeName}` : safeName;
const written = await this.app.vault.createBinary(path, buffer);
const link = this.app.fileManager.generateMarkdownLink(written, activeFile.path);
const md = (link.startsWith('!') ? link : `!${link}`) + '\n';
ctx.editor.replaceRange(md, pos);
pos = { line: pos.line + 1, ch: 0 };
}
ctx.editor.setCursor(pos);
ctx.editor.focus();
}
private attachmentDirFor(_file: TFile): string {
const cfgRecord = (this.app.vault as unknown as { config?: { attachmentFolderPath?: string } }).config;
const cfg = cfgRecord?.attachmentFolderPath;
if (cfg && cfg !== '/' && cfg !== '') return cfg.replace(/^\/+|\/+$/g, '');
return '';
}
private async ensureDir(dir: string): Promise<void> {
if (!dir) return;
const exists = this.app.vault.getAbstractFileByPath(dir);
if (exists) return;
await this.app.vault.createFolder(dir);
}
private async uniqueFileName(dir: string, name: string): Promise<string> {
const dot = name.lastIndexOf('.');
const base = dot > 0 ? name.substring(0, dot) : name;
const ext = dot > 0 ? name.substring(dot) : '';
let candidate = name;
let counter = 1;
const prefix = dir ? `${dir}/` : '';
while (this.app.vault.getAbstractFileByPath(`${prefix}${candidate}`)) {
candidate = `${base}-${counter}${ext}`;
counter++;
}
return candidate;
}
}

38
src/destinations/types.ts Normal file
View file

@ -0,0 +1,38 @@
import type { Editor, EditorPosition, MarkdownView } from 'obsidian';
export type DropGateDestinationId = 'vault' | 'imagekit' | 'imgur';
export interface DropGateContext {
editor: Editor;
view: MarkdownView;
/**
* The original event. Vault destination uses this to re-dispatch a
* synthetic copy into Obsidian's internal clipboardManager. Hosted
* destinations don't need it.
*/
originalEvent: DragEvent | ClipboardEvent;
/**
* Cursor position captured synchronously when the drop/paste fired.
* Hosted destinations insert their markdown link at this position via
* editor.replaceRange using replaceSelection instead would insert at
* whatever the cursor became while the modal was open, which is rarely
* where the user dropped the image.
*/
insertPos: EditorPosition;
}
export interface DropGateDestination {
id: DropGateDestinationId;
label: string;
description: string;
/**
* True if this destination is configured and usable. Disabled destinations
* are hidden from the modal.
*/
isAvailable(): boolean;
/**
* Process and insert the supplied images into the active document. Throw
* on failure; the handler will surface a Notice.
*/
insert(files: readonly File[], ctx: DropGateContext): Promise<void>;
}

View file

@ -0,0 +1,185 @@
import type { Editor, EditorPosition, MarkdownFileInfo, MarkdownView } from 'obsidian';
import { MarkdownView as MarkdownViewClass, Notice } from 'obsidian';
import type { DropGateDestination, DropGateDestinationId } from '../destinations/types';
import { DropGateModal } from '../modals/DropGateModal';
import {
DragEventCopy,
PasteEventCopy,
allFilesAreImages,
imagesIn,
} from '../utils/dropGateEvents';
import type ImageGinPlugin from '../../main';
function asMarkdownView(info: MarkdownView | MarkdownFileInfo): MarkdownView | null {
return info instanceof MarkdownViewClass ? info : null;
}
interface SessionState {
rememberedDestination: DropGateDestinationId | null;
}
export class DropGateHandlers {
private session: SessionState = { rememberedDestination: null };
constructor(
private readonly plugin: ImageGinPlugin,
private readonly destinations: readonly DropGateDestination[],
) {}
/** Reset the per-session "remember choice" — wired to active-leaf-change. */
resetSession(): void {
this.session.rememberedDestination = null;
}
onDrop = (e: DragEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): void => {
// Re-entry guard. The synthetic copy our own VaultDestination
// dispatches would otherwise loop back through here.
if (e instanceof DragEventCopy) return;
const view = asMarkdownView(info);
if (!view) return; // Canvas etc.: out of scope.
const settings = this.plugin.settings.dropGate;
if (!settings.enabled) return;
const files = Array.from(e.dataTransfer?.files ?? []);
if (!allFilesAreImages(files)) return;
// Synchronous preventDefault — must come before any await.
e.preventDefault();
// Capture cursor NOW. After awaiting the modal, the cursor reported
// by editor.getCursor() may be stale or somewhere else entirely
// (the user could have clicked, the editor could have lost focus).
const insertPos = editor.getCursor();
if (settings.policyMode === 'external-only' && !this.anyExternalAvailable()) {
this.dispatchVault(e, files, editor, view, insertPos);
return;
}
// Floating promise on purpose — the handler must return synchronously.
void this.gate(e, files, editor, view, insertPos);
};
onPaste = (e: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): void => {
if (e instanceof PasteEventCopy) return;
const view = asMarkdownView(info);
if (!view) return;
const settings = this.plugin.settings.dropGate;
if (!settings.enabled) return;
const files = Array.from(e.clipboardData?.files ?? []);
if (!allFilesAreImages(files)) return;
e.preventDefault();
const insertPos = editor.getCursor();
if (settings.policyMode === 'external-only' && !this.anyExternalAvailable()) {
this.dispatchVault(e, files, editor, view, insertPos);
return;
}
void this.gate(e, files, editor, view, insertPos);
};
private async gate(
originalEvent: DragEvent | ClipboardEvent,
files: File[],
editor: Editor,
view: MarkdownView,
insertPos: EditorPosition,
): Promise<void> {
const images = imagesIn(files);
const settings = this.plugin.settings.dropGate;
const remembered = this.session.rememberedDestination;
if (remembered) {
const dest = this.destinations.find((d) => d.id === remembered);
if (dest && dest.isAvailable()) {
await this.runDestination(dest, images, originalEvent, editor, view, insertPos);
return;
}
this.session.rememberedDestination = null;
}
const available = this.destinations.filter((d) => d.isAvailable());
const defaultId = available.find((d) => d.id === settings.defaultDestination)
? settings.defaultDestination
: (available[0]?.id ?? 'vault');
// Show every destination in the modal — disabled ones included — so
// users see what's possible and don't get a separate plugin's modal
// for an option that "should have been here." Per-destination hint
// tells them why a row is greyed out.
const imgurEnabled = this.plugin.settings.imgur.enabled;
const imageKitEnabled = this.plugin.settings.imageKit.enabled;
const unavailableHints = {
imagekit: !imageKitEnabled
? 'Enable ImageKit and add a private key in settings.'
: 'Missing private key or upload endpoint in settings.',
imgur: !imgurEnabled
? 'Enable Imgur and add a client ID in settings.'
: 'Missing Imgur client ID in settings.',
};
const modal = new DropGateModal(this.plugin.app, {
files: images,
destinations: this.destinations,
unavailableHints,
defaultDestinationId: defaultId,
showRememberToggle: settings.rememberSessionChoice,
});
const choice = await modal.ask();
if (choice.kind === 'cancel') return;
if (!choice.destinationId) return;
const dest = this.destinations.find((d) => d.id === choice.destinationId);
if (!dest || !dest.isAvailable()) return;
if (choice.rememberForSession) {
this.session.rememberedDestination = dest.id;
}
await this.runDestination(dest, images, originalEvent, editor, view, insertPos);
}
private async runDestination(
dest: DropGateDestination,
files: File[],
originalEvent: DragEvent | ClipboardEvent,
editor: Editor,
view: MarkdownView,
insertPos: EditorPosition,
): Promise<void> {
try {
await dest.insert(files, { editor, view, originalEvent, insertPos });
} catch (err) {
console.error(`[image-gin/drop-gate] ${dest.id} destination failed`, err);
const msg = err instanceof Error ? err.message : String(err);
new Notice(`Image Gin (${dest.label}): ${msg}`);
// Do NOT silently fall back to vault — the user picked this
// destination deliberately. Surface the error and stop.
}
}
private dispatchVault(
originalEvent: DragEvent | ClipboardEvent,
files: File[],
editor: Editor,
view: MarkdownView,
insertPos: EditorPosition,
): void {
const vault = this.destinations.find((d) => d.id === 'vault');
if (!vault) return;
void vault.insert(files, { editor, view, originalEvent, insertPos });
}
private anyExternalAvailable(): boolean {
return this.destinations.some((d) => d.id !== 'vault' && d.isAvailable());
}
}

View file

@ -1,6 +1,7 @@
import { logger } from '../utils/logger';
import type { App, TFile } from 'obsidian';
import { Modal, Setting, Notice, FileSystemAdapter } from 'obsidian';
import type { ToggleComponent } from 'obsidian';
import type ImageGinPlugin from '../../main';
import { ImageKitService } from '../services/imagekitService';
import { readFileSync } from 'fs';
@ -22,6 +23,14 @@ export class ConvertLocalImagesForCurrentFile extends Modal {
private selectedProperties: Set<string> = new Set();
private selectedMarkdownImages: Set<string> = new Set();
// Refs for header "All" master toggles and the per-row toggles they
// command, mirroring the IdeogramModal pattern so master and rows
// stay in sync when either is clicked.
private fmMasterToggle: ToggleComponent | null = null;
private fmRowToggles: Map<string, ToggleComponent> = new Map();
private mdMasterToggle: ToggleComponent | null = null;
private mdRowToggles: Map<string, ToggleComponent> = new Map();
// Common image properties to check
private readonly IMAGE_PROPERTIES = [
'banner_image',
@ -112,6 +121,19 @@ export class ConvertLocalImagesForCurrentFile extends Modal {
}
}
// Pre-select every eligible row. The common flow is "convert
// everything in this file" (banner + portrait + square, plus
// any inline markdown images), so default-on removes the
// three-clicks-every-time friction the user hit. Already-remote
// frontmatter URLs stay deselected because their toggle is
// disabled regardless.
for (const property of this.imageProperties) {
if (property.isLocalFile) this.selectedProperties.add(property.key);
}
for (const md of this.markdownImagePaths) {
this.selectedMarkdownImages.add(md.path);
}
logger.info('Found image properties:', this.imageProperties);
logger.info('Found markdown images:', this.markdownImagePaths);
} catch (error) {
@ -155,19 +177,11 @@ export class ConvertLocalImagesForCurrentFile extends Modal {
// Frontmatter image properties list
if (this.imageProperties.length > 0) {
contentEl.createEl('h3', {
text: 'Frontmatter Images',
cls: 'image-gin-section-header'
});
this.renderImagePropertiesList(contentEl);
}
// Markdown content images list
if (this.markdownImagePaths.length > 0) {
contentEl.createEl('h3', {
text: 'Markdown Content Images',
cls: 'image-gin-section-header'
});
this.renderMarkdownImagesList(contentEl);
}
@ -178,12 +192,80 @@ export class ConvertLocalImagesForCurrentFile extends Modal {
this.renderConvertButton(contentEl);
}
/**
* Build a section header (label + right-aligned "All" master toggle)
* matching the IdeogramModal Image Sizes section. Returns the toggle
* component so the caller can wire it up to its row toggles.
*/
private renderSectionHeaderWithMasterToggle(
section: HTMLElement,
title: string,
initialAllSelected: boolean,
onMasterChange: (value: boolean) => void
): ToggleComponent | null {
const header = section.createDiv('image-gin-section-header');
header.style.display = 'flex';
header.style.justifyContent = 'space-between';
header.style.alignItems = 'center';
header.createEl('span', { text: title });
const masterWrap = header.createDiv();
masterWrap.style.display = 'flex';
masterWrap.style.alignItems = 'center';
masterWrap.style.gap = '0.5rem';
masterWrap.createEl('span', {
text: 'All',
attr: { style: 'font-size: 0.85em; opacity: 0.75;' },
});
let captured: ToggleComponent | null = null;
new Setting(masterWrap)
.setClass('image-gin-master-toggle')
.addToggle(toggle => {
captured = toggle;
toggle.setValue(initialAllSelected);
toggle.setTooltip(`Toggle all ${title.toLowerCase()} on/off`);
toggle.onChange(onMasterChange);
});
// Strip the Setting component's left-info column so the toggle
// hugs the header's right edge (matches IdeogramModal).
masterWrap.querySelectorAll('.setting-item-info').forEach(el => el.remove());
return captured;
}
private renderImagePropertiesList(containerEl: HTMLElement): void {
const listContainer = containerEl.createDiv('image-gin-properties-list');
const section = containerEl.createDiv('image-gin-section');
// Eligible = local files; already-ImageKit URLs are excluded from
// the master toggle's universe so flipping master-on never tries
// to "select" an already-converted entry.
const eligible = this.imageProperties.filter(p => p.isLocalFile);
this.fmMasterToggle = this.renderSectionHeaderWithMasterToggle(
section,
'Frontmatter Images',
this.areAllFmEligibleSelected(),
(value) => {
if (value) {
for (const p of eligible) this.selectedProperties.add(p.key);
} else {
for (const p of eligible) this.selectedProperties.delete(p.key);
}
for (const p of eligible) {
const t = this.fmRowToggles.get(p.key);
if (t) t.setValue(this.selectedProperties.has(p.key));
}
this.updateConvertButtonState();
}
);
const content = section.createDiv('image-gin-section-content');
const toggleGroup = content.createDiv('image-gin-toggle-group');
this.imageProperties.forEach((prop) => {
const itemEl = listContainer.createDiv('image-gin-property-item');
const itemEl = toggleGroup.createDiv('image-gin-toggle-item');
const isAlreadyImageKit = !prop.isLocalFile;
const statusClass = isAlreadyImageKit ? 'already-imagekit' : 'local-file';
itemEl.addClass(statusClass);
@ -192,6 +274,7 @@ export class ConvertLocalImagesForCurrentFile extends Modal {
.setName(prop.key)
.setDesc(`${prop.value} ${isAlreadyImageKit ? '(Already ImageKit URL)' : '(Local file)'}`)
.addToggle(toggle => {
if (!isAlreadyImageKit) this.fmRowToggles.set(prop.key, toggle);
toggle.setValue(prop.isLocalFile && this.selectedProperties.has(prop.key));
toggle.setDisabled(isAlreadyImageKit);
toggle.onChange((value) => {
@ -200,12 +283,26 @@ export class ConvertLocalImagesForCurrentFile extends Modal {
} else {
this.selectedProperties.delete(prop.key);
}
if (this.fmMasterToggle) {
this.fmMasterToggle.setValue(this.areAllFmEligibleSelected());
}
this.updateConvertButtonState();
});
});
});
}
private areAllFmEligibleSelected(): boolean {
const eligible = this.imageProperties.filter(p => p.isLocalFile);
if (eligible.length === 0) return false;
return eligible.every(p => this.selectedProperties.has(p.key));
}
private areAllMdSelected(): boolean {
if (this.markdownImagePaths.length === 0) return false;
return this.markdownImagePaths.every(m => this.selectedMarkdownImages.has(m.path));
}
private renderProgressSection(containerEl: HTMLElement): void {
this.progressEl = containerEl.createDiv('image-gin-progress');
this.progressEl.style.display = 'none';
@ -217,15 +314,36 @@ export class ConvertLocalImagesForCurrentFile extends Modal {
}
private renderMarkdownImagesList(containerEl: HTMLElement): void {
const listContainer = containerEl.createDiv('image-gin-markdown-images-list');
const section = containerEl.createDiv('image-gin-section');
this.mdMasterToggle = this.renderSectionHeaderWithMasterToggle(
section,
'Markdown Content Images',
this.areAllMdSelected(),
(value) => {
if (value) {
for (const m of this.markdownImagePaths) this.selectedMarkdownImages.add(m.path);
} else {
this.selectedMarkdownImages.clear();
}
for (const [path, t] of this.mdRowToggles) {
t.setValue(this.selectedMarkdownImages.has(path));
}
this.updateConvertButtonState();
}
);
const content = section.createDiv('image-gin-section-content');
const toggleGroup = content.createDiv('image-gin-toggle-group');
this.markdownImagePaths.forEach((image, index) => {
const itemEl = listContainer.createDiv('image-gin-markdown-item');
const itemEl = toggleGroup.createDiv('image-gin-toggle-item');
new Setting(itemEl)
.setName(`Image ${index + 1}: ${image.path}`)
.setDesc(`Found in markdown content`)
.addToggle(toggle => {
this.mdRowToggles.set(image.path, toggle);
toggle.setValue(this.selectedMarkdownImages.has(image.path));
toggle.onChange((value) => {
if (value) {
@ -233,6 +351,9 @@ export class ConvertLocalImagesForCurrentFile extends Modal {
} else {
this.selectedMarkdownImages.delete(image.path);
}
if (this.mdMasterToggle) {
this.mdMasterToggle.setValue(this.areAllMdSelected());
}
this.updateConvertButtonState();
});
});

132
src/modals/DropGateModal.ts Normal file
View file

@ -0,0 +1,132 @@
import type { App } from 'obsidian';
import { Modal } from 'obsidian';
import type { DropGateDestination, DropGateDestinationId } from '../destinations/types';
import { formatFileSize } from '../utils/dropGateEvents';
export interface DropGateChoice {
kind: 'destination' | 'cancel';
destinationId?: DropGateDestinationId;
rememberForSession: boolean;
}
interface DialogOptions {
files: readonly File[];
/** All destinations in display order. Each carries its own isAvailable(). */
destinations: readonly DropGateDestination[];
/** Per-destination message shown under the label when isAvailable() is false. */
unavailableHints: Partial<Record<DropGateDestinationId, string>>;
defaultDestinationId: DropGateDestinationId;
showRememberToggle: boolean;
}
export class DropGateModal extends Modal {
private readonly opts: DialogOptions;
private resolveChoice: ((c: DropGateChoice) => void) | null = null;
private selectedId: DropGateDestinationId;
private rememberForSession = false;
constructor(app: App, opts: DialogOptions) {
super(app);
this.opts = opts;
this.selectedId = opts.defaultDestinationId;
}
ask(): Promise<DropGateChoice> {
return new Promise((resolve) => {
this.resolveChoice = resolve;
this.open();
});
}
onOpen(): void {
const { contentEl, modalEl } = this;
// Attach the styling class to modalEl so width rules apply to the
// OUTER popup. See perplexed widening reminder.
modalEl.addClass('image-gin-drop-gate-modal');
contentEl.empty();
const header = contentEl.createDiv('idg-header');
const title = this.opts.files.length === 1
? 'Image dropped'
: `${this.opts.files.length} images dropped`;
header.createEl('h2', { text: title, cls: 'idg-title' });
header.createEl('p', { text: 'Where should this go?', cls: 'idg-subtitle' });
const fileList = contentEl.createDiv('idg-file-list');
for (const f of this.opts.files) {
const row = fileList.createDiv('idg-file-row');
row.createEl('span', { text: f.name || 'image', cls: 'idg-file-name' });
const meta: string[] = [];
if (f.size > 0) meta.push(formatFileSize(f.size));
const t = f.type.replace(/^image\//, '');
if (t) meta.push(t);
row.createEl('span', { text: meta.join(' · '), cls: 'idg-file-meta' });
}
const destList = contentEl.createDiv('idg-destinations');
for (const dest of this.opts.destinations) {
const available = dest.isAvailable();
const row = destList.createEl('label', {
cls: `idg-destination${available ? '' : ' idg-destination-disabled'}`,
});
const radio = row.createEl('input', {
type: 'radio',
attr: { name: 'idg-destination', value: dest.id },
});
radio.checked = available && dest.id === this.selectedId;
radio.disabled = !available;
radio.addEventListener('change', () => {
if (radio.checked) this.selectedId = dest.id;
});
const text = row.createDiv('idg-destination-text');
text.createEl('div', { text: dest.label, cls: 'idg-destination-label' });
text.createEl('div', { text: dest.description, cls: 'idg-destination-desc' });
if (!available) {
const hint = this.opts.unavailableHints[dest.id]
?? 'Not configured. Enable and configure in image-gin settings.';
text.createEl('div', { text: hint, cls: 'idg-destination-hint' });
}
}
if (this.opts.showRememberToggle) {
const rememberRow = contentEl.createEl('label', { cls: 'idg-remember' });
const cb = rememberRow.createEl('input', { type: 'checkbox' });
cb.addEventListener('change', () => {
this.rememberForSession = cb.checked;
});
rememberRow.createEl('span', { text: 'Remember choice for this session' });
}
const footer = contentEl.createDiv('idg-footer');
const cancelBtn = footer.createEl('button', { text: 'Cancel', cls: 'idg-cancel' });
cancelBtn.addEventListener('click', () => this.finish({ kind: 'cancel', rememberForSession: false }));
const insertBtn = footer.createEl('button', { text: 'Insert', cls: 'mod-cta idg-insert' });
insertBtn.addEventListener('click', () => this.finish({
kind: 'destination',
destinationId: this.selectedId,
rememberForSession: this.rememberForSession,
}));
setTimeout(() => insertBtn.focus(), 0);
}
onClose(): void {
const { contentEl, modalEl } = this;
modalEl.removeClass('image-gin-drop-gate-modal');
contentEl.empty();
if (this.resolveChoice) {
const r = this.resolveChoice;
this.resolveChoice = null;
r({ kind: 'cancel', rememberForSession: false });
}
}
private finish(choice: DropGateChoice): void {
const r = this.resolveChoice;
this.resolveChoice = null;
this.close();
if (r) r(choice);
}
}

View file

@ -148,6 +148,26 @@ export interface ImageCacheSettings {
cleanupDays: number;
}
export interface ImgurSettings {
enabled: boolean;
clientId: string;
}
export type DropGatePolicyMode = 'always-confirm' | 'external-only';
export interface DropGateSettings {
enabled: boolean;
policyMode: DropGatePolicyMode;
defaultDestination: 'vault' | 'imagekit' | 'imgur';
rememberSessionChoice: boolean;
/**
* Override folder for drop-gate uploads to ImageKit. When empty, falls
* back to the main imageKit.uploadFolder. Lets the user route ad-hoc
* dropped images somewhere different from generated images.
*/
imageKitFolder: string;
}
export interface ImageGinSettings {
recraftApiKey: string;
recraftBaseUrl: string;
@ -162,9 +182,11 @@ export interface ImageGinSettings {
imageStylesJSON: string;
imageOutputFolder: string;
imageKit: ImageKitSettings;
imgur: ImgurSettings;
magnific: MagnificSettings;
ideogram: IdeogramSettings;
imageCache: ImageCacheSettings;
dropGate: DropGateSettings;
recraftLastSession: RecraftSessionState;
}
@ -248,6 +270,17 @@ export const DEFAULT_SETTINGS: ImageGinSettings = {
autoCleanup: true,
cleanupDays: 30,
},
imgur: {
enabled: false,
clientId: '',
},
dropGate: {
enabled: false,
policyMode: 'always-confirm',
defaultDestination: 'vault',
rememberSessionChoice: true,
imageKitFolder: '',
},
recraftLastSession: {
selectedSizes: [],
writeToFrontmatter: true,
@ -880,6 +913,104 @@ export class ImageGinSettingTab extends PluginSettingTab {
// Load and display cache stats
void this.loadCacheStats(statsDiv);
}
// ─── Drop Gate ──────────────────────────────────────────────
containerEl.createEl('h3', { text: 'Drag-Drop / Paste Confirmation Gate' });
containerEl.createEl('p', {
text: 'When enabled, every image dropped or pasted into a note opens a confirmation modal asking where it should go: vault attachments, ImageKit, or Imgur. Built for writers who handle private client imagery and want every image destination to be a deliberate decision.',
cls: 'image-gin-settings-blurb',
});
new Setting(containerEl)
.setName('Enable drop gate')
.setDesc('Intercept image drops and pastes; show the confirmation modal.')
.addToggle((t) =>
t.setValue(this.plugin.settings.dropGate.enabled).onChange(async (v) => {
this.plugin.settings.dropGate.enabled = v;
await this.plugin.saveSettings();
this.display();
})
);
if (this.plugin.settings.dropGate.enabled) {
new Setting(containerEl)
.setName('Policy mode')
.setDesc('When should the gate intercept?')
.addDropdown((dd) => {
dd.addOption('always-confirm', 'Always confirm');
dd.addOption('external-only', 'Confirm only if an external destination is enabled');
dd.setValue(this.plugin.settings.dropGate.policyMode);
dd.onChange(async (v) => {
this.plugin.settings.dropGate.policyMode = v as DropGatePolicyMode;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('Default destination')
.setDesc('Pre-selected when the modal opens.')
.addDropdown((dd) => {
dd.addOption('vault', 'Vault attachments');
dd.addOption('imagekit', 'ImageKit (private CDN)');
dd.addOption('imgur', 'Imgur (public CDN)');
dd.setValue(this.plugin.settings.dropGate.defaultDestination);
dd.onChange(async (v) => {
this.plugin.settings.dropGate.defaultDestination =
v as DropGateSettings['defaultDestination'];
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('Show "remember for session" checkbox')
.setDesc('Lets the user skip the modal for the current note. Never persists across Obsidian restarts.')
.addToggle((t) =>
t.setValue(this.plugin.settings.dropGate.rememberSessionChoice).onChange(async (v) => {
this.plugin.settings.dropGate.rememberSessionChoice = v;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('ImageKit folder for drop-gate uploads')
.setDesc(
`Folder path on ImageKit where dropped/pasted images go. Leave blank to use the main ImageKit upload folder ("${this.plugin.settings.imageKit.uploadFolder || '(unset)'}").`
)
.addText((t) => {
t.setPlaceholder('/uploads/lossless/drops');
t.setValue(this.plugin.settings.dropGate.imageKitFolder).onChange(async (v) => {
this.plugin.settings.dropGate.imageKitFolder = v;
await this.plugin.saveSettings();
});
});
}
// ─── Imgur (public CDN) ─────────────────────────────────────
containerEl.createEl('h3', { text: 'Imgur (public CDN)' });
new Setting(containerEl)
.setName('Enable Imgur destination')
.setDesc('Anonymous upload via a client ID. Public — use for non-sensitive imagery only.')
.addToggle((t) =>
t.setValue(this.plugin.settings.imgur.enabled).onChange(async (v) => {
this.plugin.settings.imgur.enabled = v;
await this.plugin.saveSettings();
this.display();
})
);
if (this.plugin.settings.imgur.enabled) {
new Setting(containerEl)
.setName('Imgur client ID')
.setDesc('Anonymous client ID from imgur.com/account → Applications. Not the secret.')
.addText((t) => {
t.inputEl.type = 'password';
t.setValue(this.plugin.settings.imgur.clientId).onChange(async (v) => {
this.plugin.settings.imgur.clientId = v;
await this.plugin.saveSettings();
});
});
}
}
private async loadCacheStats(container: HTMLElement) {

View file

@ -1,6 +1,7 @@
/* image-gin/src/styles/current-file-modal.css */
@import './magnific.css';
@import './drop-gate.css';
/* ===== Base Modal Styles =====
Scoped to our modal classes only DO NOT bare-target `.modal`.

175
src/styles/drop-gate.css Normal file
View file

@ -0,0 +1,175 @@
/* image-gin/src/styles/drop-gate.css
*
* Drop-gate confirmation modal. Class attaches to modalEl (the OUTER popup),
* not contentEl, so width rules actually take effect. See
* perplexed/context-v/issues/Widen-Modals-in-Obsidian-using-CSS.md.
*/
.image-gin-drop-gate-modal {
width: 90vw;
max-width: 720px;
max-height: 88vh;
}
.image-gin-drop-gate-modal .modal-content {
padding: 0;
display: flex;
flex-direction: column;
max-height: calc(88vh - 60px);
}
.image-gin-drop-gate-modal .idg-destinations {
flex: 1 1 auto;
overflow-y: auto;
}
.image-gin-drop-gate-modal .idg-header {
padding: 1.25rem 1.5rem 0.75rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.image-gin-drop-gate-modal .idg-title {
margin: 0 0 0.35rem;
font-size: 1.25rem;
font-weight: 600;
}
.image-gin-drop-gate-modal .idg-subtitle {
margin: 0;
color: var(--text-muted);
font-size: 0.95rem;
}
.image-gin-drop-gate-modal .idg-file-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.85rem 1.5rem;
background: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
max-height: 8rem;
overflow-y: auto;
flex-shrink: 0;
}
.image-gin-drop-gate-modal .idg-file-row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.85rem;
}
.image-gin-drop-gate-modal .idg-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-normal);
font-family: var(--font-monospace);
}
.image-gin-drop-gate-modal .idg-file-meta {
color: var(--text-muted);
flex-shrink: 0;
}
.image-gin-drop-gate-modal .idg-destinations {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 1rem 1.5rem;
}
.image-gin-drop-gate-modal .idg-destination {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.85rem 1rem;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease;
}
.image-gin-drop-gate-modal .idg-destination:hover:not(.idg-destination-disabled) {
border-color: var(--interactive-accent);
background: var(--background-modifier-hover);
}
.image-gin-drop-gate-modal .idg-destination-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.image-gin-drop-gate-modal .idg-destination-disabled .idg-destination-hint {
color: var(--text-error, var(--text-muted));
font-style: italic;
font-size: 0.78rem;
margin-top: 0.2rem;
}
.image-gin-drop-gate-modal .idg-destination input[type='radio'] {
margin-top: 0.2rem;
accent-color: var(--interactive-accent);
}
.image-gin-drop-gate-modal .idg-destination-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.image-gin-drop-gate-modal .idg-destination-label {
font-weight: 600;
color: var(--text-normal);
}
.image-gin-drop-gate-modal .idg-destination-desc {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.35;
}
.image-gin-drop-gate-modal .idg-remember {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 1.5rem 0.75rem;
color: var(--text-muted);
font-size: 0.85rem;
cursor: pointer;
}
.image-gin-drop-gate-modal .idg-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.85rem 1.5rem;
border-top: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
}
.image-gin-drop-gate-modal .idg-cancel,
.image-gin-drop-gate-modal .idg-insert {
padding: 0.4rem 1rem;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
}
.image-gin-drop-gate-modal .idg-cancel:hover {
background: var(--background-modifier-hover);
}
.image-gin-drop-gate-modal .idg-insert.mod-cta {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.image-gin-drop-gate-modal .idg-insert.mod-cta:hover {
background: var(--interactive-accent-hover);
}

View file

@ -0,0 +1,71 @@
/**
* Re-entry guards and helpers for the drag-drop / paste interception flow.
*
* The two synthetic event subclasses are how we re-dispatch a drop or paste
* into Obsidian's internal clipboardManager without our own handler picking
* it up again `instanceof` check at the top of each handler bails out
* early, breaking the would-be loop.
*/
export class DragEventCopy extends DragEvent {
static create(original: DragEvent, files: readonly File[]): DragEventCopy {
const dt = new DataTransfer();
files.forEach((f) => dt.items.add(f));
return new DragEventCopy(original.type, {
dataTransfer: dt,
clientX: original.clientX,
clientY: original.clientY,
bubbles: true,
cancelable: true,
});
}
}
export class PasteEventCopy extends ClipboardEvent {
static create(original: ClipboardEvent, files: readonly File[]): PasteEventCopy {
const dt = new DataTransfer();
files.forEach((f) => dt.items.add(f));
return new PasteEventCopy(original.type, {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
}
}
export function allFilesAreImages(files: readonly File[]): boolean {
if (files.length === 0) return false;
return files.every((f) => f.type.startsWith('image/'));
}
export function imagesIn(files: readonly File[]): File[] {
return files.filter((f) => f.type.startsWith('image/'));
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
const MIME_TO_EXT: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'image/tiff': 'tiff',
'image/heic': 'heic',
'image/avif': 'avif',
};
export function fileNameFor(file: File, fallbackBase = 'image'): string {
if (file.name && file.name.trim() !== '' && file.name !== 'image.png') {
return file.name;
}
const ext = MIME_TO_EXT[file.type.toLowerCase()] ?? 'png';
const ts = new Date().toISOString().replace(/[:.]/g, '-');
return `${fallbackBase}-${ts}.${ext}`;
}

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,6 @@
{
"0.0.9": "1.8.10",
"0.1.0": "1.8.10",
"0.1.1": "1.8.10"
"0.1.1": "1.8.10",
"0.2.0": "1.8.10"
}