feat: add per-image exclude/delete actions and precise multi-image drag-out

- Show exclude/delete buttons on hover for images inside a group; deleting an
  image still referenced elsewhere in the vault now asks for confirmation
  before touching the original file.
- Track each image's own start/end offset within its line (getImageMatches)
  so dragging one image out of a line with multiple images no longer removes
  or disturbs its neighbors.
- Group the image settings panel into labeled sections (Canvas size /
  Appearance) for clarity.

Also satisfies Obsidian's plugin lint pass: bump tsconfig `lib` to ES2019
(Object.entries/Array.flat were silently type-checked only via @types/node's
hidden lib reference, not the project's own lib config), switch remaining
bare `document` event listeners to `activeDocument` for popout-window
compatibility, and replace `el.closest(...) as HTMLElement | null` with the
explicit `el.closest<HTMLElement>(...)` generic form (the `as` cast was
itself supplying the contextual type that made the assertion look
redundant to `no-unnecessary-type-assertion`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tcyeee 2026-07-10 17:27:13 +08:00
parent 5f3c0b9cd8
commit 4ff21baf2c
13 changed files with 520 additions and 68 deletions

View file

@ -10,9 +10,14 @@ export const STANDALONE_IMAGE_DRAG_MIME = "application/x-imgcluster-image";
export interface StandaloneDragPayload {
/** 源图片所在文件路径;落盘时必须与目标图片组所在文件一致,防止跨文件误删行 */
sourcePath: string;
/** 该图片在文件中所在行的行号0 基),用于落盘时删除原始行 */
/** 该图片在文件中所在行的行号0 基) */
lineIndex: number;
/** 该行原始 Markdown 文本(如 "![[a.png]]"),落盘时作为新的一行插入目标代码块 */
/**
* lineIndex 0 getImageMatches
* /
*/
matchIndex: number;
/** 这张图片自身的 Markdown 语法文本(如 "![[a.png]]"),落盘时作为新的一行插入目标代码块 */
markdown: string;
}

View file

@ -18,8 +18,8 @@ function resolveLineTarget(
const el = activeDocument.elementFromPoint(clientX, clientY) as HTMLElement | null;
if (!el || el.closest(".plugin-image-container")) return null;
const lineElement = el.closest(".cm-line") as HTMLElement | null;
const editorRoot = el.closest(".cm-editor") as HTMLElement | null;
const lineElement = el.closest<HTMLElement>(".cm-line");
const editorRoot = el.closest<HTMLElement>(".cm-editor");
if (!lineElement || !editorRoot) return null;
const view = EditorView.findFromDOM(editorRoot);
@ -44,7 +44,7 @@ function clearLineIndicators(): void {
* Markdown
*/
export function registerEditorDropTarget(plugin: ImgRowPlugin): void {
plugin.registerDomEvent(document, "dragover", (e: DragEvent) => {
plugin.registerDomEvent(activeDocument, "dragover", (e: DragEvent) => {
if (!e.dataTransfer?.types.includes(GROUP_IMAGE_DRAG_MIME)) return;
clearLineIndicators();
const target = resolveLineTarget(e.clientX, e.clientY);
@ -54,7 +54,7 @@ export function registerEditorDropTarget(plugin: ImgRowPlugin): void {
target.lineEl.classList.add(target.before ? LINE_BEFORE_CLASS : LINE_AFTER_CLASS);
});
plugin.registerDomEvent(document, "drop", (e: DragEvent) => {
plugin.registerDomEvent(activeDocument, "drop", (e: DragEvent) => {
if (!e.dataTransfer?.types.includes(GROUP_IMAGE_DRAG_MIME)) return;
e.preventDefault();
clearLineIndicators();
@ -69,7 +69,7 @@ export function registerEditorDropTarget(plugin: ImgRowPlugin): void {
void persistDragOutToSource(groupDrag, plugin, target.lineIndex, target.before);
});
plugin.registerDomEvent(document, "dragend", () => {
plugin.registerDomEvent(activeDocument, "dragend", () => {
clearLineIndicators();
clearGroupDrag();
});

View file

@ -39,7 +39,7 @@ export function registerHoverGroupTrigger(plugin: ImgRowPlugin) {
};
const convertImageToGroup = (img: HTMLImageElement) => {
const editorRoot = img.closest(".cm-editor") as HTMLElement | null;
const editorRoot = img.closest<HTMLElement>(".cm-editor");
const view = editorRoot ? EditorView.findFromDOM(editorRoot) : null;
if (!view) return;
@ -101,6 +101,6 @@ export function registerHoverGroupTrigger(plugin: ImgRowPlugin) {
// 保证按钮在 embed 获得焦点(原生 edit 按钮出现的时机)前已存在于 DOM。
// 用 e.targetNodeObsidian 对 UIEvent 的跨窗口安全扩展)而不是裸的 e.target
// 这样它的类型是 Node可以直接配合 .instanceOf() 做跨窗口安全的类型判断。
plugin.registerDomEvent(document, "mouseover", (e: MouseEvent) => maybeInject(e.targetNode));
plugin.registerDomEvent(document, "focusin", (e: FocusEvent) => maybeInject(e.targetNode));
plugin.registerDomEvent(activeDocument, "mouseover", (e: MouseEvent) => maybeInject(e.targetNode));
plugin.registerDomEvent(activeDocument, "focusin", (e: FocusEvent) => maybeInject(e.targetNode));
}

View file

@ -1,7 +1,7 @@
import ImgRowPlugin from "main";
import { MarkdownView } from "obsidian";
import { EditorView } from "@codemirror/view";
import { isSingleImageLine } from "./markdown/image-syntax";
import { getImageMatches } from "./markdown/image-syntax";
import { STANDALONE_IMAGE_DRAG_MIME, setCurrentDrag, clearCurrentDrag } from "./drag-state";
/** 根据 DOM 节点找到承载它的 Markdown 文件路径,用于校验拖拽源和落点必须同一个文件 */
@ -27,35 +27,48 @@ export function registerImageDragSource(plugin: ImgRowPlugin) {
return true;
};
plugin.registerDomEvent(document, "dragstart", (e: DragEvent) => {
plugin.registerDomEvent(activeDocument, "dragstart", (e: DragEvent) => {
if (!plugin.settings.enableDragToGroup) return;
if (!isEligibleImage(e.target)) return;
const img = e.target;
const editorRoot = img.closest(".cm-editor") as HTMLElement | null;
const editorRoot = img.closest<HTMLElement>(".cm-editor");
const view = editorRoot ? EditorView.findFromDOM(editorRoot) : null;
if (!view) return;
const pos = view.posAtDOM(img);
const line = view.state.doc.lineAt(pos);
if (!isSingleImageLine(line.text)) {
// 同一行还有其他图片或正文内容,整行删除不安全,取消这次拖拽
const matches = getImageMatches(line.text);
if (matches.length === 0) {
e.preventDefault();
return;
}
// Live Preview 用 widget 替换了图片语法对应的源码区间posAtDOM 返回的正是该区间的
// 起点;用「落在 [start, end) 内」定位这是当前行第几张图片,同一行写了多张图片时
// 也能精确区分具体拖的是哪一张——而不是像过去那样,只要同行有其他内容/图片就整体
// 禁止拖拽。
const offsetInLine = pos - line.from;
let matchIndex = matches.findIndex(m => offsetInLine >= m.start && offsetInLine < m.end);
if (matchIndex === -1) matchIndex = matches.length - 1;
const sourcePath = getFilePathForNode(plugin, img);
if (!sourcePath) {
e.preventDefault();
return;
}
setCurrentDrag({ sourcePath, lineIndex: line.number - 1, markdown: line.text });
setCurrentDrag({
sourcePath,
lineIndex: line.number - 1,
matchIndex,
markdown: matches[matchIndex].text,
});
e.dataTransfer?.setData(STANDALONE_IMAGE_DRAG_MIME, "1");
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
});
plugin.registerDomEvent(document, "dragend", () => {
plugin.registerDomEvent(activeDocument, "dragend", () => {
clearCurrentDrag();
// 兜底清理:正常情况下容器的 dragleave/drop 会自己摘掉高亮,
// 这里防止极端情况下(比如拖拽被系统中途取消)残留高亮效果

View file

@ -50,15 +50,44 @@ export function hasMarkdownImage(line: string): boolean {
return /!\[.*?\]\((.*?)\)/.test(line) || /!\[\[.*?\]\]/.test(line);
}
/**
*
*
*/
export function isSingleImageLine(line: string): boolean {
const imageRegex = /!\[.*?\]\((.*?)\)|!\[\[.*?\]\]/g;
const matches = line.match(imageRegex);
if (!matches || matches.length !== 1) return false;
// 去掉图片语法和列表标记(- / * / + / 1.)后,剩余内容应为空,避免误删同一行的其他文本
const remainder = line.replace(imageRegex, "").replace(/^\s*([-*+]|\d+\.)\s*/, "").trim();
return remainder.length === 0;
export interface ImageMatch {
/** 图片语法本身的文本,如 "![[a.png]]" */
text: string;
/** 在行内的起始字符偏移(含) */
start: number;
/** 在行内的结束字符偏移(不含) */
end: number;
}
/**
*
*
*/
export function getImageMatches(line: string): ImageMatch[] {
const imageRegex = /!\[.*?\]\((.*?)\)|!\[\[.*?\]\]/g;
const matches: ImageMatch[] = [];
let match: RegExpExecArray | null;
while ((match = imageRegex.exec(line)) !== null) {
matches.push({ text: match[0], start: match.index, end: match.index + match[0].length });
}
return matches;
}
/**
* /
*
* empty true
*
* @param matchIndex - getImageMatches(line) 0
* @returns text - trim
* empty - - / * / + / 1.
*/
export function removeImageFromLine(line: string, matchIndex: number): { text: string; empty: boolean } {
const matches = getImageMatches(line);
const target = matches[matchIndex];
if (!target) return { text: line, empty: false };
const removed = (line.slice(0, target.start) + line.slice(target.end)).replace(/[ \t]+/g, " ").trim();
const withoutListMarker = removed.replace(/^\s*([-*+]|\d+\.)\s*/, "").trim();
return { text: removed, empty: withoutListMarker.length === 0 };
}

View file

@ -3,6 +3,7 @@ import { MarkdownPostProcessorContext, Notice, TFile } from "obsidian";
import { EditorView } from "@codemirror/view";
import { SettingOptions } from "../core/domain";
import { GroupDragPayload } from "../drag-state";
import { removeImageFromLine } from "./image-syntax";
/**
* -> ->
@ -39,7 +40,7 @@ function enqueueFileOp<T>(path: string, op: () => Promise<T>): Promise<T> {
*
*/
function readCurrentContent(plugin: ImgRowPlugin, file: TFile, el: HTMLElement): Promise<string> {
const editorRoot = el.closest(".cm-editor") as HTMLElement | null;
const editorRoot = el.closest<HTMLElement>(".cm-editor");
const view = editorRoot ? EditorView.findFromDOM(editorRoot) : null;
if (view) return Promise.resolve(view.state.doc.toString());
return plugin.app.vault.read(file);
@ -66,7 +67,7 @@ async function writeFileContent(
oldContent: string,
newContent: string,
): Promise<void> {
const editorRoot = el.closest(".cm-editor") as HTMLElement | null;
const editorRoot = el.closest<HTMLElement>(".cm-editor");
const view = editorRoot ? EditorView.findFromDOM(editorRoot) : null;
if (view && view.state.doc.toString() === oldContent) {
@ -233,6 +234,117 @@ export async function persistReorderToSource(
});
}
/**
*
* 0 1 2 fence
* persistRemoveImageFromSource persistExcludeImageToSource
* persistDragOutToSource
*/
function buildShrunkGroupBlockLines(
lines: string[],
lineStart: number,
lineEnd: number,
remainingImageLines: string[],
): string[] {
if (remainingImageLines.length === 0) return [];
if (remainingImageLines.length === 1) return [remainingImageLines[0]];
const innerLines = lines.slice(lineStart + 1, lineEnd);
const configLine = innerLines.find(l => l.includes(";;")) ?? null;
const newInner = configLine ? [configLine, ...remainingImageLines] : remainingImageLines;
return [lines[lineStart], ...newInner, lines[lineEnd]];
}
/**
*
* Markdown
* -- enqueueFileOp
*
* @param container - wrapper DOM
* DOM
*/
export async function persistRemoveImageFromSource(
container: HTMLDivElement,
plugin: ImgRowPlugin,
ctx: MarkdownPostProcessorContext,
el: HTMLElement,
): Promise<void> {
await enqueueFileOp(ctx.sourcePath, async () => {
const section = ctx.getSectionInfo(el);
if (!section) return;
const file = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
if (!(file instanceof TFile)) return;
const content = await readCurrentContent(plugin, file, el);
const lines = content.split("\n");
const { lineStart, lineEnd } = section;
if (lineStart < 0 || lineEnd >= lines.length || lineStart > lineEnd) return;
// 按 DOM 当前顺序(调用方已移除被删除的 wrapper读取剩余图片行
const wrappers = Array.from(container.querySelectorAll<HTMLElement>(".plugin-image-wrapper"));
const remainingImageLines = wrappers.map(w => w.dataset.imgLine).filter(Boolean) as string[];
const newBlockLines = buildShrunkGroupBlockLines(lines, lineStart, lineEnd, remainingImageLines);
const newLines = [
...lines.slice(0, lineStart),
...newBlockLines,
...lines.slice(lineEnd + 1),
];
await writeFileContent(plugin, file, el, content, newLines.join("\n"));
});
}
/**
*
*
* buildShrunkGroupBlockLines
* -- enqueueFileOp
*
* @param container - wrapper DOM
* DOM
* @param excludedImageLine - Markdown wrapper.dataset.imgLine
*/
export async function persistExcludeImageToSource(
container: HTMLDivElement,
plugin: ImgRowPlugin,
ctx: MarkdownPostProcessorContext,
el: HTMLElement,
excludedImageLine: string,
): Promise<void> {
await enqueueFileOp(ctx.sourcePath, async () => {
const section = ctx.getSectionInfo(el);
if (!section) return;
const file = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath);
if (!(file instanceof TFile)) return;
const content = await readCurrentContent(plugin, file, el);
const lines = content.split("\n");
const { lineStart, lineEnd } = section;
if (lineStart < 0 || lineEnd >= lines.length || lineStart > lineEnd) return;
const wrappers = Array.from(container.querySelectorAll<HTMLElement>(".plugin-image-wrapper"));
const remainingImageLines = wrappers.map(w => w.dataset.imgLine).filter(Boolean) as string[];
const newBlockLines = buildShrunkGroupBlockLines(lines, lineStart, lineEnd, remainingImageLines);
// 被排除的图片放到图片组下方,上下各留一行空行
const excludedLines = ["", excludedImageLine, ""];
const newLines = [
...lines.slice(0, lineStart),
...newBlockLines,
...excludedLines,
...lines.slice(lineEnd + 1),
];
await writeFileContent(plugin, file, el, content, newLines.join("\n"));
});
}
/**
*
* DOM
@ -243,6 +355,9 @@ export async function persistReorderToSource(
* @param sourcePath - ctx.sourcePath
*
* @param sourceLineIndex - 0
* @param sourceMatchIndex - sourceLineIndex 0
* /
*
* @returns drag-sort.ts wrapper
* DOM "图片显示进了图片组,但源位置的图片也没消失"
*/
@ -253,6 +368,7 @@ export async function persistDragInsertToSource(
el: HTMLElement,
sourcePath: string,
sourceLineIndex: number,
sourceMatchIndex: number,
): Promise<boolean> {
if (sourcePath !== ctx.sourcePath) return false;
@ -267,13 +383,19 @@ export async function persistDragInsertToSource(
const lines = content.split("\n");
if (sourceLineIndex < 0 || sourceLineIndex >= lines.length) return false;
// 先删除源图片所在行;如果它在目标代码块之前,代码块的行号范围要整体减一
lines.splice(sourceLineIndex, 1);
// 只移除源图片所在行里被拖走的这一张;若移除后该行再无有意义内容(忽略列表标记),
// 才把整行一并删除——此时如果它在目标代码块之前,代码块的行号范围要整体减一。
const { text: remainingLineText, empty } = removeImageFromLine(lines[sourceLineIndex], sourceMatchIndex);
let innerStart = section.lineStart + 1;
let innerEnd = section.lineEnd;
if (sourceLineIndex < innerStart) {
innerStart -= 1;
innerEnd -= 1;
if (empty) {
lines.splice(sourceLineIndex, 1);
if (sourceLineIndex < innerStart) {
innerStart -= 1;
innerEnd -= 1;
}
} else {
lines[sourceLineIndex] = remainingLineText;
}
if (innerStart >= innerEnd || innerStart < 0 || innerEnd > lines.length) return false;

View file

@ -0,0 +1,46 @@
import { App, Modal, Setting } from "obsidian";
/**
*
* vault image-actions.ts
* excludeImageBelowGroup退
*
*/
export class ConfirmDeleteImageModal extends Modal {
constructor(
app: App,
private otherRefCount: number,
private onChoice: (deleteOriginal: boolean) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h3", { text: "Delete image" });
contentEl.createEl("p", {
text: `This image is referenced in ${this.otherRefCount} other place${this.otherRefCount > 1 ? "s" : ""} in your vault. Deleting the original file will break those references.`,
});
new Setting(contentEl)
.addButton(btn => btn
.setButtonText("Cancel")
.onClick(() => this.close()))
.addButton(btn => btn
.setButtonText("Remove from group only")
.onClick(() => {
this.close();
this.onChoice(false);
}))
.addButton(btn => btn
.setButtonText("Delete original anyway")
.setWarning()
.onClick(() => {
this.close();
this.onChoice(true);
}));
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -58,7 +58,7 @@ export function enableDragSort(
} else {
container.appendChild(tempWrapper);
}
void persistDragInsertToSource(container, plugin, ctx, el, drag.sourcePath, drag.lineIndex).then(ok => {
void persistDragInsertToSource(container, plugin, ctx, el, drag.sourcePath, drag.lineIndex, drag.matchIndex).then(ok => {
if (ok) return;
// 落盘失败(例如源图片行号在读取时已失效):乐观插入的临时 wrapper 必须撤销,
// 否则图片会同时出现在"已拖入的图片组"和"源位置"两个地方。

View file

@ -41,12 +41,22 @@ export function createSettingPanelDom(sizeGroupName: string): SettingPanelDom {
sizeGroup.appendChild(createSizeRadio("large", "L", sizeGroupName));
const panel = createDiv({ cls: "plugin-image-setting-panel" });
panel.appendChild(sizeGroup);
panel.appendChild(createSettingCheckbox("border", "border"));
panel.appendChild(createSettingCheckbox("shadow", "shadow"));
panel.appendChild(createSettingCheckbox("hidden", "hidden"));
panel.appendChild(createSettingCheckbox("limit", "limit"));
panel.appendChild(createSettingCheckbox("padding-left", "padding-left"));
const sizeSection = createDiv({ cls: "plugin-image-setting-section" });
sizeSection.appendChild(createSectionTitle("Canvas size"));
sizeSection.appendChild(sizeGroup);
panel.appendChild(sizeSection);
const appearanceSection = createDiv({ cls: "plugin-image-setting-section" });
appearanceSection.appendChild(createSectionTitle("Appearance"));
const checkboxList = createDiv({ cls: "plugin-image-setting-checkbox-list" });
checkboxList.appendChild(createSettingCheckbox("border", "border"));
checkboxList.appendChild(createSettingCheckbox("shadow", "shadow"));
checkboxList.appendChild(createSettingCheckbox("hidden", "hidden"));
checkboxList.appendChild(createSettingCheckbox("limit", "limit"));
checkboxList.appendChild(createSettingCheckbox("padding-left", "padding-left"));
appearanceSection.appendChild(checkboxList);
panel.appendChild(appearanceSection);
const borderCheckbox = panel.querySelector<HTMLInputElement>('input[data-setting="border"]');
const shadowCheckbox = panel.querySelector<HTMLInputElement>('input[data-setting="shadow"]');
@ -60,6 +70,11 @@ export function createSettingPanelDom(sizeGroupName: string): SettingPanelDom {
return { panel, borderCheckbox, shadowCheckbox, hiddenCheckbox, limitCheckbox, paddingLeftCheckbox, sizeRadios };
}
// 面板分组标题(如 "Canvas size" / "Appearance"
function createSectionTitle(text: string) {
return createDiv({ cls: "plugin-image-setting-section-title", text });
}
// 尺寸选项单选(内部仍然使用 radio外观是按钮组
function createSizeRadio(sizeKey: "small" | "medium" | "large", labelText: string, sizeGroupName: string) {
const label = createEl("label", { cls: "plugin-image-setting-size-radio" });

123
src/render/image-actions.ts Normal file
View file

@ -0,0 +1,123 @@
import ImgRowPlugin from "main";
import { MarkdownPostProcessorContext, TFile, normalizePath, setIcon } from "obsidian";
import { config } from "../core/config";
import { md5 } from "../thumbnail/md5";
import { persistExcludeImageToSource, persistRemoveImageFromSource } from "../markdown/persistence";
import { ConfirmDeleteImageModal } from "./confirm-delete-modal";
/**
* vault file /
*
*/
function countVaultReferences(plugin: ImgRowPlugin, file: TFile): number {
const resolvedLinks = plugin.app.metadataCache.resolvedLinks;
let total = 0;
for (const sourcePath in resolvedLinks) {
total += resolvedLinks[sourcePath]?.[file.path] ?? 0;
}
return total;
}
/** 排除:把图片移出图片组,重新以独立图片行的形式放到组正下方(缓存与原图不受影响)。 */
function excludeImageBelowGroup(
wrapper: HTMLElement,
container: HTMLDivElement,
plugin: ImgRowPlugin,
ctx: MarkdownPostProcessorContext,
el: HTMLElement,
): void {
const imgLine = wrapper.dataset.imgLine ?? "";
wrapper.remove();
void persistExcludeImageToSource(container, plugin, ctx, el, imgLine);
}
/** 删除:原图已被移除,图片组内不能再保留指向它的这一行,直接从组内摘除(不回填到组下方)。 */
function removeImageFromGroup(
wrapper: HTMLElement,
container: HTMLDivElement,
plugin: ImgRowPlugin,
ctx: MarkdownPostProcessorContext,
el: HTMLElement,
): void {
wrapper.remove();
void persistRemoveImageFromSource(container, plugin, ctx, el);
}
/** 删除缓存缩略图与原图文件(走系统/Obsidian 回收站,而非直接抹除,与核心「删除文件」命令保持一致)。 */
async function deleteImageFile(plugin: ImgRowPlugin, file: TFile): Promise<void> {
const thumbKey = md5(file.path);
const thumbPath = normalizePath(`${config.THUMBNAIL_PATH}${thumbKey}`);
const thumbFile = plugin.app.vault.getAbstractFileByPath(thumbPath);
if (thumbFile instanceof TFile) {
await plugin.app.fileManager.trashFile(thumbFile);
}
await plugin.app.fileManager.trashFile(file);
}
/**
* hover
*
* -
*
* - vault
*
*
*/
export function attachImageWrapperActions(
wrapper: HTMLElement,
file: TFile,
container: HTMLDivElement,
plugin: ImgRowPlugin,
ctx: MarkdownPostProcessorContext,
el: HTMLElement,
): void {
const overlay = createDiv({ cls: "plugin-image-item-overlay" });
wrapper.appendChild(overlay);
const actions = createDiv({ cls: "plugin-image-item-actions" });
const excludeBtn = createDiv({ cls: "plugin-image-item-action-btn clickable-icon" });
excludeBtn.setAttribute("aria-label", "Remove from group");
setIcon(excludeBtn, "circle-minus");
excludeBtn.addEventListener("mousedown", e => e.stopPropagation());
excludeBtn.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
excludeImageBelowGroup(wrapper, container, plugin, ctx, el);
});
const deleteBtn = createDiv({ cls: "plugin-image-item-action-btn plugin-image-item-action-btn--danger clickable-icon" });
deleteBtn.setAttribute("aria-label", "Delete image");
setIcon(deleteBtn, "trash-2");
deleteBtn.addEventListener("mousedown", e => e.stopPropagation());
deleteBtn.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
const otherRefCount = Math.max(0, countVaultReferences(plugin, file) - 1);
if (otherRefCount === 0) {
// 没有被其他地方引用:直接删除,无需弹窗确认
void (async () => {
await deleteImageFile(plugin, file);
removeImageFromGroup(wrapper, container, plugin, ctx, el);
})();
return;
}
new ConfirmDeleteImageModal(plugin.app, otherRefCount, (deleteOriginal) => {
void (async () => {
if (deleteOriginal) {
await deleteImageFile(plugin, file);
removeImageFromGroup(wrapper, container, plugin, ctx, el);
} else {
// 用户不同意删除原图:等同于排除,仅从组中移除
excludeImageBelowGroup(wrapper, container, plugin, ctx, el);
}
})();
}).open();
});
actions.appendChild(excludeBtn);
actions.appendChild(deleteBtn);
wrapper.appendChild(actions);
}

View file

@ -10,6 +10,7 @@ import { createImage } from "./image";
import { createContainer } from "./container";
import { applySettingsToContainer } from "./layout";
import { enableDragSort } from "./drag-sort";
import { attachImageWrapperActions } from "./image-actions";
// el -> 这个 el 上最近一次调用本处理器时创建的 container。
// 如果同一个 el 在其 requestAnimationFrame 回调触发前又被重新渲染了一次
@ -77,6 +78,8 @@ export function addImageLayoutMarkdownProcessor(plugin: ImgRowPlugin) {
wrapper.dataset.imgLine = line.trim(); // 保存原始 markdown 行,供拖拽排序写回使用
wrapper.appendChild(imgEl);
container.appendChild(wrapper);
// 悬停时出现的「排除 / 删除」按钮
attachImageWrapperActions(wrapper, file, container, plugin, ctx, el);
// 如果当前还没有缩略图,则在后台异步生成一份,并在生成后刷新当前 img 的 src
if (!(thumbFile instanceof TFile)) {

View file

@ -227,17 +227,17 @@
position: fixed;
top: var(--plugin-panel-top, 0);
right: var(--plugin-panel-right, 0);
min-width: 160px;
min-width: 250px;
display: none;
background: var(--background-primary);
border-radius: 10px;
border-radius: 16px;
box-shadow: 0 6px 24px rgba(0,0,0,0.13);
padding: 17px 26px 16px 18px;
padding: 20px 20px 18px 20px;
z-index: 110;
font-size: 15px;
color: var(--text-normal);
flex-direction: column;
gap: 18px;
gap: 22px;
}
.plugin-image-setting-panel.plugin-image-setting-panel--open {
display: flex;
@ -250,13 +250,34 @@
right: 0;
height: 12px;
}
/* 面板分组:标题 + 内容 */
.plugin-image-setting-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-image-setting-section-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint, var(--text-muted));
}
/* 勾选项列表 */
.plugin-image-setting-checkbox-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.plugin-image-setting-checkbox {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: var(--text-muted);
font-size: 14px;
color: var(--text-normal);
}
.plugin-image-setting-panel input[type="checkbox"] {
accent-color: var(--interactive-accent);
@ -266,8 +287,8 @@
}
.plugin-image-setting-switch {
position: relative;
width: 34px;
height: 18px;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.plugin-image-setting-switch-input {
@ -290,8 +311,8 @@
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--toggle-thumb-color, #ffffff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
@ -302,14 +323,17 @@
box-shadow: 0 0 0 1px rgba(40, 90, 180, 0.35);
}
.plugin-image-setting-switch-input:checked + .plugin-image-setting-switch-track::before {
transform: translateX(16px);
transform: translateX(18px);
}
.plugin-image-setting-size-group {
position: relative;
display: inline-flex;
display: flex;
align-items: center;
width: 100%;
height: 40px;
box-sizing: border-box;
padding: 3px;
border-radius: 999px;
border-radius: 12px;
background: var(--background-secondary, rgba(240, 242, 247, 0.9));
gap: 0;
}
@ -319,7 +343,7 @@
bottom: 3px;
left: 3px;
width: calc((100% - 6px) / 3);
border-radius: 999px;
border-radius: 9px;
background: var(--interactive-accent, #5677c0);
box-shadow: 0 0 0 1px rgba(40, 90, 180, 0.35);
transition: transform 0.18s ease;
@ -334,35 +358,51 @@
.plugin-image-setting-size-group[data-size="large"] .plugin-image-setting-size-slider {
transform: translateX(200%);
}
.workspace .plugin-image-setting-size-radio {
.plugin-image-setting-size-radio {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.plugin-image-setting-size-radio {
position: relative;
flex: 1 1 0;
height: 100%;
margin: 0;
padding: 0;
cursor: pointer;
font-size: 12.5px;
font-family: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1;
letter-spacing: normal;
color: var(--text-muted);
z-index: 1;
box-sizing: border-box;
}
.plugin-image-setting-size-radio-input {
position: absolute;
width: 0;
height: 0;
margin: 0;
padding: 0;
opacity: 0;
pointer-events: none;
}
.plugin-image-setting-size-radio-text {
padding: 4px 0;
min-width: 28px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
min-width: 14px;
padding: 0;
margin: 0;
font-size: inherit;
line-height: 1;
text-align: center;
white-space: nowrap;
border-radius: 999px;
}
.plugin-image-setting-size-radio-input:checked + .plugin-image-setting-size-radio-text {
color: var(--text-on-accent, #ffffff);
font-weight: 500;
color: var(--text-on-accent, #ffffff81);
font-weight: 600;
}
/* ── setting 外层包装 ── */
@ -480,6 +520,64 @@ body:not(.is-mobile) div.image-embed:focus-within .plugin-image-hover-group-wrap
display: inline-flex;
}
/* 图片组内单张图片的操作按钮排除 / 删除悬停缩略图时出现
悬停时仅图片下半部分变暗呈现从下到上的渐变效果而非整张图变暗
两个按钮横向居中纵向靠下叠在蒙层之上蒙层与按钮均带淡入 + 位移动画 */
.plugin-image-item-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0.25) 35%, rgba(0, 0, 0, 0) 65%);
border-radius: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
z-index: 3;
}
.plugin-image-wrapper:hover .plugin-image-item-overlay {
opacity: 1;
}
.plugin-image-item-actions {
position: absolute;
left: 50%;
bottom: 8px;
transform: translate(-50%, 6px);
display: flex;
gap: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 4;
}
.plugin-image-wrapper:hover .plugin-image-item-actions {
opacity: 1;
pointer-events: auto;
transform: translate(-50%, 0);
}
.plugin-image-item-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-s);
background-color: var(--background-primary);
color: var(--text-muted);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.plugin-image-item-action-btn:hover {
color: var(--text-normal);
background-color: var(--background-secondary);
}
.plugin-image-item-action-btn--danger:hover {
color: var(--text-error, #e93147);
}
.icon--error-picture {
display: inline-block;
width: 20px;

View file

@ -13,9 +13,7 @@
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
"ES2019"
]
},
"include": [