mirror of
https://github.com/lossless-group/image-gin.git
synced 2026-07-22 06:49:33 +00:00
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:
parent
91922ccf80
commit
b6e7d7f6ed
19 changed files with 6408 additions and 18 deletions
155
changelog/2026-05-09_01.md
Normal file
155
changelog/2026-05-09_01.md
Normal 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.
|
||||
65
main.ts
65
main.ts
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1 +1,4 @@
|
|||
packages: []
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
|
|
|
|||
72
src/destinations/ImageKitDestination.ts
Normal file
72
src/destinations/ImageKitDestination.ts
Normal 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 = `\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();
|
||||
}
|
||||
}
|
||||
87
src/destinations/ImgurDestination.ts
Normal file
87
src/destinations/ImgurDestination.ts
Normal 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 = `\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;
|
||||
}
|
||||
}
|
||||
110
src/destinations/VaultDestination.ts
Normal file
110
src/destinations/VaultDestination.ts
Normal 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
38
src/destinations/types.ts
Normal 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>;
|
||||
}
|
||||
185
src/handlers/DropGateHandlers.ts
Normal file
185
src/handlers/DropGateHandlers.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
132
src/modals/DropGateModal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
175
src/styles/drop-gate.css
Normal 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);
|
||||
}
|
||||
71
src/utils/dropGateEvents.ts
Normal file
71
src/utils/dropGateEvents.ts
Normal 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
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue