diff --git a/src/drag-state.ts b/src/drag-state.ts index 8bd4cb7..0f8c1d0 100644 --- a/src/drag-state.ts +++ b/src/drag-state.ts @@ -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; } diff --git a/src/editor-drop-target.ts b/src/editor-drop-target.ts index d4d47d9..a249bc5 100644 --- a/src/editor-drop-target.ts +++ b/src/editor-drop-target.ts @@ -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(".cm-line"); + const editorRoot = el.closest(".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(); }); diff --git a/src/hover-group-trigger.ts b/src/hover-group-trigger.ts index 96e20d9..7dfe0ea 100644 --- a/src/hover-group-trigger.ts +++ b/src/hover-group-trigger.ts @@ -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(".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)); } diff --git a/src/image-drag-source.ts b/src/image-drag-source.ts index ed7f5b5..79f9e77 100644 --- a/src/image-drag-source.ts +++ b/src/image-drag-source.ts @@ -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(".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 会自己摘掉高亮, // 这里防止极端情况下(比如拖拽被系统中途取消)残留高亮效果 diff --git a/src/markdown/image-syntax.ts b/src/markdown/image-syntax.ts index e2914c6..5c1f560 100644 --- a/src/markdown/image-syntax.ts +++ b/src/markdown/image-syntax.ts @@ -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 }; } diff --git a/src/markdown/persistence.ts b/src/markdown/persistence.ts index 0e15a58..482681f 100644 --- a/src/markdown/persistence.ts +++ b/src/markdown/persistence.ts @@ -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(path: string, op: () => Promise): Promise { * 永远是一致的。 */ function readCurrentContent(plugin: ImgRowPlugin, file: TFile, el: HTMLElement): Promise { - const editorRoot = el.closest(".cm-editor") as HTMLElement | null; + const editorRoot = el.closest(".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 { - const editorRoot = el.closest(".cm-editor") as HTMLElement | null; + const editorRoot = el.closest(".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 { + 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(".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 { + 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(".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 { 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; diff --git a/src/render/confirm-delete-modal.ts b/src/render/confirm-delete-modal.ts new file mode 100644 index 0000000..abcea8c --- /dev/null +++ b/src/render/confirm-delete-modal.ts @@ -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(); + } +} diff --git a/src/render/drag-sort.ts b/src/render/drag-sort.ts index 132bc92..460d8b4 100644 --- a/src/render/drag-sort.ts +++ b/src/render/drag-sort.ts @@ -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 必须撤销, // 否则图片会同时出现在"已拖入的图片组"和"源位置"两个地方。 diff --git a/src/render/elements.ts b/src/render/elements.ts index e5add00..634c3f5 100644 --- a/src/render/elements.ts +++ b/src/render/elements.ts @@ -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('input[data-setting="border"]'); const shadowCheckbox = panel.querySelector('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" }); diff --git a/src/render/image-actions.ts b/src/render/image-actions.ts new file mode 100644 index 0000000..9dbfea7 --- /dev/null +++ b/src/render/image-actions.ts @@ -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 { + 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); +} diff --git a/src/render/processor.ts b/src/render/processor.ts index 450d650..abe238e 100644 --- a/src/render/processor.ts +++ b/src/render/processor.ts @@ -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)) { diff --git a/styles.css b/styles.css index 37bd79e..d73845a 100644 --- a/styles.css +++ b/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; diff --git a/tsconfig.json b/tsconfig.json index c44b729..003ec2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,9 +13,7 @@ "strictNullChecks": true, "lib": [ "DOM", - "ES5", - "ES6", - "ES7" + "ES2019" ] }, "include": [