mirror of
https://github.com/tcyeee/obsidian-image-cluster.git
synced 2026-07-22 06:40:05 +00:00
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:
parent
5f3c0b9cd8
commit
4ff21baf2c
13 changed files with 520 additions and 68 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.targetNode(Obsidian 对 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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 会自己摘掉高亮,
|
||||
// 这里防止极端情况下(比如拖拽被系统中途取消)残留高亮效果
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
46
src/render/confirm-delete-modal.ts
Normal file
46
src/render/confirm-delete-modal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 必须撤销,
|
||||
// 否则图片会同时出现在"已拖入的图片组"和"源位置"两个地方。
|
||||
|
|
|
|||
|
|
@ -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
123
src/render/image-actions.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
144
styles.css
144
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@
|
|||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
"ES2019"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
|
|
|||
Loading…
Reference in a new issue