diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ef260..56cc68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.4 + +- Move 3D node inspection details into the side panel. +- Improve long label, pin, neighbor, and button wrapping in the map panels. +- Type Obsidian saved plugin data as `unknown` before settings migration to avoid unsafe assignment warnings. + ## 0.2.3 - Add a 2D Complete map action that shows the vault root with expanded node, link, depth, and outside-link detail budgets. diff --git a/manifest.json b/manifest.json index 9090136..ec32004 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "mini-world-map", "name": "Mini World Map", - "version": "0.2.3", + "version": "0.2.4", "minAppVersion": "1.13.0", "description": "Visualize your vault as a hierarchy-first world map with internal links layered on top.", "author": "Miro0o and Codex", diff --git a/package-lock.json b/package-lock.json index 66d4a98..39f1735 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mini-world-map", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mini-world-map", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "dependencies": { "3d-force-graph": "1.80.0", diff --git a/package.json b/package.json index e103ec4..bc7a03a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mini-world-map", - "version": "0.2.3", + "version": "0.2.4", "description": "Hierarchy-first 2D radial rings and 3D map views for your Obsidian vault", "main": "main.js", "type": "module", diff --git a/src/i18n.ts b/src/i18n.ts index c9e28bb..ee7aadb 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -59,7 +59,7 @@ const STRINGS: Record> = { 'view.atlas': { en: 'Atlas', zh: '图谱' }, 'view.focus': { en: 'Focus', zh: '聚焦' }, 'view.vaultRoot': { en: 'Vault root', zh: '库根目录' }, - 'view.completeMap': { en: 'Complete map', zh: '完整地图' }, + 'view.completeMap': { en: 'Full map', zh: '完整地图' }, 'view.mode': { en: 'Map mode', zh: '地图模式' }, 'view.theme': { en: 'Theme', zh: '主题' }, 'theme.auto': { en: 'System', zh: '跟随系统' }, @@ -71,6 +71,8 @@ const STRINGS: Record> = { 'inspect.type': { en: 'Type', zh: '类型' }, 'inspect.depth': { en: 'Depth', zh: '深度' }, 'inspect.notes': { en: 'Notes', zh: '笔记' }, + 'inspect.folder': { en: 'Folder', zh: '文件夹' }, + 'inspect.modified': { en: 'Modified', zh: '修改时间' }, 'inspect.out': { en: 'Out', zh: '出链' }, 'inspect.in': { en: 'In', zh: '入链' }, 'inspect.linkOverlay': { en: 'Note link', zh: '笔记链接' }, @@ -228,6 +230,8 @@ const STRINGS: Record> = { '3d.searchPlaceholder': { en: 'Search notes, press Enter to fly…', zh: '搜索笔记,回车飞过去…' }, '3d.searchUnresolved': { en: 'Unresolved', zh: '未解析' }, '3d.searchLinks': { en: '{count} links', zh: '{count} 链接' }, + '3d.inspect.none': { en: 'Select a node to inspect it.', zh: '选择一个节点查看详情。' }, + '3d.inspect.note': { en: 'Note', zh: '笔记' }, '3d.card.unresolved': { en: 'Unresolved link (note does not exist)', zh: '未解析链接(笔记尚不存在)' }, '3d.card.root': { en: 'Vault root', zh: '根目录' }, '3d.card.stats': { en: '↩ {in} backlinks · → {out} outgoing', zh: '↩ {in} 反链 · → {out} 出链' }, diff --git a/src/main.ts b/src/main.ts index 72360b9..9f0597b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,7 +27,7 @@ export default class MiniWorldMapPlugin extends Plugin { async onload(): Promise { console.info('[Mini World Map] loading'); - const saved = await this.loadData(); + const saved: unknown = await this.loadData(); this.settings = applyVaultConfigDirDefault(mergeSettings(saved), saved, this.app.vault.configDir); this.registerView(VIEW_TYPE_MINI_WORLD_MAP, (leaf) => new MiniWorldMapView(leaf, this)); diff --git a/src/overlay/ControlPanel.ts b/src/overlay/ControlPanel.ts index dd7d7e7..a3d77e4 100644 --- a/src/overlay/ControlPanel.ts +++ b/src/overlay/ControlPanel.ts @@ -8,7 +8,18 @@ import type { VisualTokens } from '../render/presets'; import { t, viewModeLabel } from '../i18n'; import { Slider } from './Slider'; -type PanelPage = 'view' | 'appearance' | 'physics' | 'motion' | 'advanced'; +type PanelPage = 'inspect' | 'view' | 'appearance' | 'physics' | 'motion' | 'advanced'; + +export interface PanelInspectNode { + title: string; + path: string; + folder: string; + type: string; + inDegree: number; + outDegree: number; + modified: string | null; + canOpen: boolean; +} export interface ControlPanelCallbacks { onViewMode?: (mode: ViewMode) => void; @@ -29,6 +40,8 @@ export interface ControlPanelCallbacks { onSizeBy: () => void; onQuality: () => void; onSearch: () => void; + onOpenSelected: () => void; + onFocusSelected: () => void; onReset: () => void; runScenario: (s: 'S1' | 'S2' | 'S3') => void; } @@ -46,6 +59,7 @@ export class ControlPanel { private sizeBySelect: HTMLSelectElement | null = null; private qualityBtn: HTMLButtonElement | null = null; private styleChips: HTMLButtonElement[] = []; + private inspectNode: PanelInspectNode | null = null; constructor( parent: HTMLElement, @@ -87,6 +101,7 @@ export class ControlPanel { const settings = this.body.createDiv({ cls: 'mwm-panel-settings mwm-3d-settings' }); const tabs = settings.createDiv({ cls: 'mwm-panel-tabs' }); for (const [id, label] of [ + ['inspect', this.tt('tab.inspect')], ['view', this.tt('tab.view')], ['appearance', this.tt('tab.appearance')], ['physics', this.tt('tab.physics')], @@ -101,13 +116,43 @@ export class ControlPanel { } const pageEl = settings.createDiv({ cls: 'mwm-panel-page' }); - if (this.page === 'appearance') this.renderAppearancePage(pageEl); + if (this.page === 'inspect') this.renderInspectPage(pageEl); + else if (this.page === 'appearance') this.renderAppearancePage(pageEl); else if (this.page === 'physics') this.renderPhysicsPage(pageEl); else if (this.page === 'motion') this.renderMotionPage(pageEl); else if (this.page === 'advanced') this.renderAdvancedPage(pageEl); else this.renderViewPage(pageEl); } + private renderInspectPage(parent: HTMLElement): void { + const node = this.inspectNode; + if (!node) { + parent.createDiv({ cls: 'mwm-side-muted', text: this.tt('3d.inspect.none') }); + return; + } + parent.createDiv({ cls: 'mwm-side-title', text: node.title }); + const path = parent.createDiv({ cls: 'mwm-side-path mwm-scroll-path', text: node.path || this.tt('3d.card.root') }); + path.setAttr('title', node.path || this.tt('3d.card.root')); + const facts = parent.createDiv({ cls: 'mwm-facts' }); + for (const [label, value] of [ + [this.tt('inspect.type'), node.type], + [this.tt('inspect.folder'), node.folder || this.tt('3d.card.root')], + [this.tt('inspect.in'), String(node.inDegree)], + [this.tt('inspect.out'), String(node.outDegree)], + ...(node.modified ? ([[this.tt('inspect.modified'), node.modified]] as const) : []), + ] as const) { + facts.createSpan({ text: label }); + facts.createSpan({ text: value }); + } + const actions = parent.createDiv({ cls: 'galaxy-panel-row' }); + if (node.canOpen) { + const openBtn = actions.createEl('button', { text: this.tt('context.openNote') }); + openBtn.addEventListener('click', this.cb.onOpenSelected); + } + const focusBtn = actions.createEl('button', { text: this.tt('common.focus') }); + focusBtn.addEventListener('click', this.cb.onFocusSelected); + } + private renderViewPage(parent: HTMLElement): void { const row = parent.createDiv({ cls: 'galaxy-panel-row' }); const searchBtn = row.createEl('button', { text: this.tt('common.search') }); @@ -344,6 +389,12 @@ export class ControlPanel { this.render(); } + setInspectNode(node: PanelInspectNode | null, activate = false): void { + this.inspectNode = node; + if (activate) this.page = 'inspect'; + if (activate || this.page === 'inspect') this.render(); + } + setPanelTheme(cls: VisualTokens['panelClass']): void { this.root.removeClass('gx-theme-space'); this.root.removeClass('gx-theme-night'); diff --git a/src/overlay/OverlayManager.ts b/src/overlay/OverlayManager.ts index a9b043e..510efb0 100644 --- a/src/overlay/OverlayManager.ts +++ b/src/overlay/OverlayManager.ts @@ -1,18 +1,8 @@ -import type { App } from 'obsidian'; -import { TFile, getAllTags } from 'obsidian'; -import type { Language } from '../settings'; -import type { GraphData, GraphNode } from '../types'; +import type { GraphData } from '../types'; import type { AggregateRenderer } from '../render/AggregateRenderer'; -import { t } from '../i18n'; - -export interface OverlayCallbacks { - openNote: (id: string) => void; - focusNode: (index: number) => void; -} /** - * DOM 浮层(NASA 模式:标签和卡片不进画布)。 - * 硬预算:枢纽 14 + hover 1 + 邻居 ≤20 + 卡片 1 —— 每帧 ≤36 次投影,可忽略。 + * DOM labels for hub, hover, and selected-neighbor names. */ export class OverlayManager { private root: HTMLElement; @@ -20,42 +10,23 @@ export class OverlayManager { private neighborEls: { index: number; el: HTMLElement }[] = []; private hoverEl: HTMLElement; private hoverIndex = -1; - private card: HTMLElement; - private cardIndex = -1; private data: GraphData = { nodes: [], links: [] }; private graphRadius = 200; - private snippetToken = 0; private hubBudget = 14; private neighborBudget = 20; - private mobileCard = false; constructor( parent: HTMLElement, - private app: App, private renderer: AggregateRenderer, - private cb: OverlayCallbacks, - private language: Language = 'en', ) { this.root = parent.createDiv({ cls: 'gx-overlay' }); this.hoverEl = this.root.createDiv({ cls: 'gx-label gx-label-hover' }); this.hoverEl.hide(); - this.card = this.root.createDiv({ cls: 'gx-card' }); - this.card.hide(); } - setLanguage(language: Language): void { - this.language = language; - if (this.cardIndex >= 0) { - const node = this.data.nodes[this.cardIndex]; - if (node) this.buildCard(node, this.cardIndex); - } - } - - /** 质量档位预算;卡片切底部抽屉模式(移动端) */ - setBudgets(hub: number, neighbor: number, mobileCard: boolean): void { + setBudgets(hub: number, neighbor: number): void { this.hubBudget = hub; this.neighborBudget = neighbor; - this.mobileCard = mobileCard; this.setData(this.data, this.graphRadius); } @@ -88,31 +59,11 @@ export class OverlayManager { this.hoverEl.show(); } - /** - * 自适应底部留白(M4.1):实测 .mobile-navbar 与画布的重叠像素。 - * 官方未暴露 navbar 高度变量,且平板/隐藏设置/安卓变体下可能不存在—— - * 运行时测量在所有形态下自适应:无 navbar 时为 0,不会多出空白。 - */ - private refreshBottomInset(): void { - let inset = 0; - const navbar = activeDocument.querySelector('.mobile-navbar'); - if (navbar) { - const nb = navbar.getBoundingClientRect(); - const ce = this.root.getBoundingClientRect(); - inset = Math.max(0, Math.round(ce.bottom - nb.top)); - } - this.root.setCssProps({ '--gx-bottom-inset': `${inset}px` }); - } - - /** 选中:邻居标签 + 卡片;index<0 清空 */ + /** Selected node: neighbor labels only; details render in the left panel. */ setSelection(index: number, neighbors: Set): void { for (const e of this.neighborEls) e.el.remove(); this.neighborEls = []; - this.cardIndex = index; - if (index < 0) { - this.card.hide(); - return; - } + if (index < 0) return; const byDegree = [...neighbors] .filter((i) => i !== index) .sort((a, b) => (this.data.nodes[b]?.degree ?? 0) - (this.data.nodes[a]?.degree ?? 0)) @@ -121,63 +72,6 @@ export class OverlayManager { index: i, el: this.root.createDiv({ cls: 'gx-label gx-label-neighbor', text: this.data.nodes[i]?.name ?? '' }), })); - const node = this.data.nodes[index]; - if (node) { - if (this.mobileCard) { - this.refreshBottomInset(); - // 移除桌面定位残留的内联 transform → CSS 底部抽屉定位靠选择器特异性接管(免 !important) - this.card.style.removeProperty('transform'); - } - this.buildCard(node, index); - } - } - - private buildCard(node: GraphNode, index: number): void { - this.card.empty(); - this.card.show(); - - this.card.createDiv({ cls: 'gx-card-title', text: node.name }); - const meta = this.card.createDiv({ cls: 'gx-card-meta' }); - const dot = meta.createSpan({ cls: 'gx-card-dot' }); - dot.style.background = this.renderer.nodeColorHex(index); - meta.createSpan({ - text: node.unresolved ? this.tt('3d.card.unresolved') : node.id.includes('/') ? node.id.slice(0, node.id.lastIndexOf('/')) : this.tt('3d.card.root'), - }); - - const file = node.unresolved ? null : this.app.vault.getAbstractFileByPath(node.id); - const tfile = file instanceof TFile ? file : null; - - if (tfile) { - const cache = this.app.metadataCache.getFileCache(tfile); - const tags = cache ? (getAllTags(cache) ?? []) : []; - if (tags.length > 0) { - const tagRow = this.card.createDiv({ cls: 'gx-card-tags' }); - for (const t of tags.slice(0, 5)) tagRow.createSpan({ cls: 'gx-card-tag', text: t }); - } - } - - const stats = this.card.createDiv({ cls: 'gx-card-stats' }); - stats.setText( - this.tt('3d.card.stats', { in: node.inDegree, out: node.outDegree }) + - (tfile ? this.tt('3d.card.modified', { date: new Date(tfile.stat.mtime).toLocaleDateString(this.language === 'zh' ? 'zh-CN' : 'en-US') }) : ''), - ); - - if (tfile) { - const snippetEl = this.card.createDiv({ cls: 'gx-card-snippet', text: '…' }); - const token = ++this.snippetToken; - void this.app.vault.cachedRead(tfile).then((text) => { - if (token !== this.snippetToken) return; // 已切换选中,丢弃过期结果 - snippetEl.setText(stripMarkdown(text).slice(0, 120) || this.tt('3d.card.empty')); - }); - } - - const actions = this.card.createDiv({ cls: 'gx-card-actions' }); - if (!node.unresolved) { - const openBtn = actions.createEl('button', { text: this.tt('context.openNote') }); - openBtn.addEventListener('click', () => this.cb.openNote(node.id)); - } - const focusBtn = actions.createEl('button', { text: this.tt('common.focus') }); - focusBtn.addEventListener('click', () => this.cb.focusNode(index)); } /** 每帧:投影所有被追踪节点,translate3d 定位(GPU 合成,无重排) */ @@ -204,15 +98,6 @@ export class OverlayManager { const p = this.renderer.projectNode(this.hoverIndex, w, h); if (!p.behind) this.hoverEl.style.transform = `translate3d(${p.x.toFixed(1)}px, ${(p.y - 18).toFixed(1)}px, 0)`; } - if (this.cardIndex >= 0 && !this.mobileCard) { - const p = this.renderer.projectNode(this.cardIndex, w, h); - if (!p.behind) { - const flip = p.x + 296 > w; - const x = flip ? p.x - 296 : p.x + 16; - const y = Math.min(Math.max(p.y - 40, 12), Math.max(h - this.card.clientHeight - 12, 12)); - this.card.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`; - } - } } dispose(): void { @@ -220,18 +105,4 @@ export class OverlayManager { this.hubEls = []; this.neighborEls = []; } - - private tt(key: string, vars: Record = {}): string { - return t(this.language, key, vars); - } -} - -function stripMarkdown(text: string): string { - return text - .replace(/^---\n[\s\S]*?\n---\n?/, '') // frontmatter - .replace(/!?\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, '$1') - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') - .replace(/[#*`>~_]|---/g, '') - .replace(/\s+/g, ' ') - .trim(); } diff --git a/src/view/GraphController.ts b/src/view/GraphController.ts index 884c3b9..d2599fe 100644 --- a/src/view/GraphController.ts +++ b/src/view/GraphController.ts @@ -1,5 +1,5 @@ import type { App } from 'obsidian'; -import { Menu, Notice, Platform, debounce, setIcon } from 'obsidian'; +import { Menu, Notice, Platform, TFile, debounce, setIcon } from 'obsidian'; import { Spherical, Vector3 } from 'three'; import type { BenchResult } from '../types'; import type { GalaxySettings, Language, ViewMode } from '../settings'; @@ -18,7 +18,7 @@ import { DAYLIGHT, DEEP_SPACE, NIGHT } from '../render/presets'; import type { VisualTokens } from '../render/presets'; import { resolveObsidianBackground } from '../render/obsidianTheme'; import { CameraDirector } from '../interactions/CameraDirector'; -import { ControlPanel } from '../overlay/ControlPanel'; +import { ControlPanel, type PanelInspectNode } from '../overlay/ControlPanel'; import { OverlayManager } from '../overlay/OverlayManager'; import { NodeSearchModal } from './SearchModal'; import { collectFrames, observeLongTasks, writeBenchResult, sleep } from '../bench/bench'; @@ -104,10 +104,7 @@ export class GraphController { onResetView: () => this.recenter(), }); - this.overlay = new OverlayManager(this.contentEl, this.app, renderer, { - openNote: (id) => void this.app.workspace.openLinkText(id, '', true), - focusNode: (i) => this.selectNode(i, true), - }, this.language); + this.overlay = new OverlayManager(this.contentEl, renderer); this.overlay.setData(this.store.data, this.graphRadius); this.applySettings(); @@ -275,7 +272,7 @@ export class GraphController { const prev = this.tier.id; this.tier = this.pickTier(); this.renderer?.applyTier(this.tier, this.settings.bloom.strength); - this.overlay?.setBudgets(this.tier.hubLabels, this.tier.neighborLabels, this.tier.id === 'mobile'); + this.overlay?.setBudgets(this.tier.hubLabels, this.tier.neighborLabels); this.contentEl.toggleClass('gx-mobile', this.tier.id === 'mobile'); const total = this.app.vault.getMarkdownFiles().length; this.store.setCaps(this.tier.nodeCap, this.tier.linkCap); // 变化时触发重建 @@ -454,6 +451,7 @@ export class GraphController { renderer.setFocus(index, neighbors); renderer.setSelectedLinks(linkIdx); this.overlay?.setSelection(index, neighbors); + this.panel?.setInspectNode(this.panelInspectNode(index), true); if (fly) { const pos = renderer.nodePosition(index, new Vector3()); // 邻居质心方向:到达后环绕优先扫过链接密集的一侧 @@ -475,6 +473,31 @@ export class GraphController { this.renderer?.setFocus(-1, null); this.renderer?.setSelectedLinks([]); this.overlay?.setSelection(-1, new Set()); + this.panel?.setInspectNode(null); + } + + private panelInspectNode(index: number): PanelInspectNode | null { + const node = this.store.data.nodes[index]; + if (!node) return null; + const file = node.unresolved ? null : this.app.vault.getAbstractFileByPath(node.id); + const tfile = file instanceof TFile ? file : null; + const folder = node.unresolved ? this.tt('3d.searchUnresolved') : node.id.includes('/') ? node.id.slice(0, node.id.lastIndexOf('/')) : this.tt('3d.card.root'); + return { + title: node.name, + path: node.unresolved ? node.id : node.id || this.tt('3d.card.root'), + folder, + type: node.unresolved ? this.tt('3d.searchUnresolved') : this.tt('3d.inspect.note'), + inDegree: node.inDegree, + outDegree: node.outDegree, + modified: tfile ? new Date(tfile.stat.mtime).toLocaleDateString(this.language === 'zh' ? 'zh-CN' : 'en-US') : null, + canOpen: !node.unresolved, + }; + } + + private openSelectedNode(): void { + const node = this.store.data.nodes[this.selected]; + if (!node || node.unresolved) return; + void this.app.workspace.openLinkText(node.id, '', true); } private flyToSelected(): void { @@ -605,6 +628,8 @@ export class GraphController { this.saveSoon(); }, onSearch: () => this.openSearch(), + onOpenSelected: () => this.openSelectedNode(), + onFocusSelected: () => this.flyToSelected(), onReset: () => { Object.assign(this.settings.bloom, DEFAULT_GALAXY_SETTINGS.bloom); Object.assign(this.settings.physics, DEFAULT_GALAXY_SETTINGS.physics); @@ -644,7 +669,6 @@ export class GraphController { item.onClick(() => { if (value === this.language) return; this.language = value; - this.overlay?.setLanguage(value); this.panel?.setLanguage(value); this.onLanguage?.(value); }); diff --git a/src/view/Radial2DController.ts b/src/view/Radial2DController.ts index b692f67..799890a 100644 --- a/src/view/Radial2DController.ts +++ b/src/view/Radial2DController.ts @@ -572,13 +572,16 @@ export class Radial2DController extends Component { [this.t('inspect.outgoing', { count: outgoing.length }), outgoing, 'target'], [this.t('inspect.backlinks', { count: incoming.length }), incoming, 'source'], ] as const) { - parent.createDiv({ cls: 'mwm-side-heading', text: title }); - if (edges.length === 0) parent.createDiv({ cls: 'mwm-side-muted', text: this.t('common.none') }); + const section = parent.createDiv({ cls: 'mwm-neighbor-section' }); + section.createDiv({ cls: 'mwm-side-heading', text: title }); + if (edges.length === 0) section.createDiv({ cls: 'mwm-side-muted', text: this.t('common.none') }); + const list = section.createDiv({ cls: 'mwm-neighbor-list' }); for (const edge of edges) { const neighbor = this.index.nodes.get(edge[side]); if (!neighbor) continue; const label = `${neighbor.title} (${edge.weight})`; - const button = parent.createEl('button', { cls: 'mwm-link-row', text: label, attr: { title: label } }); + const button = list.createEl('button', { cls: 'mwm-link-row', attr: { title: label } }); + button.createSpan({ cls: 'mwm-link-row-label', text: label }); button.addEventListener('click', () => this.inspectNode(neighbor.id)); } } @@ -636,14 +639,18 @@ export class Radial2DController extends Component { else this.selectedPinIds.delete(pin.id); this.renderPanel(); }); - const main = row.createEl('button', { cls: 'mwm-pin-main', attr: { type: 'button', title: pin.path || pin.title } }); + const main = row.createEl('button', { cls: 'mwm-pin-main', attr: { type: 'button' } }); main.addEventListener('click', () => this.locatePin(pin)); - main.createDiv({ cls: 'mwm-pin-title', text: pin.title }); - main.createDiv({ cls: 'mwm-pin-meta', text: `${this.pinKindLabel(pin)} - ${pin.path || '/'}` }); - this.button(row, pin.active ? this.t('pins.hideHighlight') : this.t('pins.showHighlight'), () => this.togglePinHighlight(pin.id)); - this.button(row, this.t('common.inspect'), () => this.inspectPin(pin)); - if (pin.groupId) this.button(row, this.t('common.ungroup'), () => this.ungroupPin(pin.id)); - this.button(row, 'X', () => this.removePin(pin.id)); + const displayTitle = this.pinDisplayTitle(pin); + const displayPath = pin.path || '/'; + main.setAttr('title', `${displayTitle}\n${this.pinKindLabel(pin)} - ${displayPath}`); + main.createDiv({ cls: 'mwm-pin-title', text: displayTitle }); + main.createDiv({ cls: 'mwm-pin-meta', text: displayPath }); + const actions = row.createDiv({ cls: 'mwm-pin-actions' }); + this.button(actions, pin.active ? this.t('pins.hideHighlight') : this.t('pins.showHighlight'), () => this.togglePinHighlight(pin.id)); + this.button(actions, this.t('common.inspect'), () => this.inspectPin(pin)); + if (pin.groupId) this.button(actions, this.t('common.ungroup'), () => this.ungroupPin(pin.id)); + this.button(actions, 'X', () => this.removePin(pin.id)); } private renderViewPage(parent: HTMLElement): void { @@ -990,7 +997,8 @@ export class Radial2DController extends Component { } private button(parent: HTMLElement, label: string, onClick: () => void, active = false): HTMLButtonElement { - const button = parent.createEl('button', { cls: active ? 'is-active' : '', text: label, attr: { title: label } }); + const button = parent.createEl('button', { cls: active ? 'is-active' : '', attr: { title: label } }); + button.createSpan({ cls: 'mwm-button-label', text: label }); button.addEventListener('click', onClick); return button; } @@ -1162,6 +1170,23 @@ export class Radial2DController extends Component { return pin.kind === 'link' ? this.t('inspect.linkOverlay') : this.t(`hover.${normalizeHoverHighlightMode(pin.mode)}`); } + private pinDisplayTitle(pin: PinPath): string { + if (pin.kind === 'link') { + const [source = pin.source ?? '', target = pin.target ?? ''] = pin.path.split(' -> '); + const sourceName = this.pathLeaf(source) || this.pathLeaf(pin.source ?? '') || this.t('common.source'); + const targetName = this.pathLeaf(target) || this.pathLeaf(pin.target ?? '') || this.t('common.target'); + return `${sourceName} -> ${targetName}`; + } + return this.pathLeaf(pin.path) || pin.title; + } + + private pathLeaf(path: string): string { + const clean = path.trim().replace(/[/\\]+$/, ''); + if (!clean || clean === '/') return ''; + const leaf = clean.split(/[/\\]/).filter(Boolean).pop() ?? clean; + return leaf.endsWith('.md') ? leaf.slice(0, -3) : leaf; + } + private clearDisallowedHoverTargets(): void { const targets = this.hoverTargets(); if (!targets.nodes) this.hoverNodeId = null; diff --git a/styles.css b/styles.css index 6c460d5..b92edb8 100644 --- a/styles.css +++ b/styles.css @@ -18,6 +18,8 @@ /* ---------- 控制面板 ---------- */ .galaxy-panel { + --mwm-action-button-min-height: 40px; + --mwm-link-row-min-height: 46px; position: absolute; top: 10px; left: 10px; @@ -138,6 +140,7 @@ .galaxy-panel-body { padding: 0 10px 10px; overflow-y: auto; + overflow-x: hidden; min-height: 0; scrollbar-gutter: stable; } @@ -159,8 +162,8 @@ } .galaxy-panel-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(108px, 1fr)); + display: flex; + flex-wrap: wrap; gap: 6px; margin-top: 10px; align-items: stretch; @@ -170,7 +173,8 @@ display: inline-flex; align-items: center; justify-content: center; - min-height: 28px; + flex: 1 1 132px; + min-height: var(--mwm-action-button-min-height); font-size: 11px; line-height: 1.2; padding: 5px 8px; @@ -183,7 +187,17 @@ min-width: 0; max-width: 100%; white-space: normal; - overflow-wrap: break-word; + overflow-wrap: anywhere; + word-break: break-word; + box-sizing: border-box; +} + +.galaxy-panel-row button .mwm-button-label { + display: block; + max-width: 100%; + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; } .galaxy-panel-row button:hover { @@ -343,98 +357,6 @@ font-weight: 600; } -.gx-card { - position: absolute; - top: 0; - left: 0; - z-index: 6; /* 卡片永远盖住浮层标签(G2 bug 修复) */ - width: 280px; - transform: translate3d(-1000px, -1000px, 0); - pointer-events: auto; - border-radius: 10px; - padding: 12px 14px; - font-size: 12px; - background: rgba(10, 14, 24, 0.78); - backdrop-filter: blur(16px); - border: 1px solid rgba(255, 255, 255, 0.08); - color: #e8ecf6; -} - -.gx-daylight .gx-card { - background: rgba(252, 250, 245, 0.88); - border: 1px solid rgba(31, 41, 51, 0.14); - color: #1f2933; -} - -.gx-card-title { - font-size: 15px; - font-weight: 600; - margin-bottom: 4px; -} - -.gx-card-meta { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - opacity: 0.7; -} - -.gx-card-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.gx-card-tags { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 8px; -} - -.gx-card-tag { - font-size: 10px; - padding: 1px 7px; - border-radius: 8px; - background: rgba(127, 155, 255, 0.18); - border: 1px solid rgba(127, 155, 255, 0.3); -} - -.gx-card-stats { - margin-top: 8px; - font-size: 11px; - opacity: 0.75; -} - -.gx-card-snippet { - margin-top: 6px; - font-size: 11px; - line-height: 1.5; - opacity: 0.65; - max-height: 50px; - overflow: hidden; -} - -.gx-card-actions { - display: flex; - gap: 6px; - margin-top: 10px; -} - -.gx-card-actions button { - flex: 1; - font-size: 11px; - padding: 3px 8px; - background: rgba(127, 127, 127, 0.12); - border: 1px solid rgba(127, 127, 127, 0.25); - color: inherit; - border-radius: 6px; - cursor: pointer; - box-shadow: none; -} - .gx-search-path { font-size: 11px; color: var(--text-muted); @@ -560,20 +482,6 @@ /* ---------- M4 移动端 ---------- */ -/* .gx-mobile .gx-card 比 .gx-card 更具体 → 靠选择器特异性覆盖 left/top/transform - (JS 已在移动端清掉内联 transform,见 OverlayManager.setSelection) */ -.gx-mobile .gx-card { - left: 8px; - right: 8px; - top: auto; - /* 自适应:实测 navbar 重叠(--gx-bottom-inset)与 iPhone 横条安全区取大者 */ - bottom: calc(10px + max(var(--gx-bottom-inset, 0px), env(safe-area-inset-bottom, 0px))); - width: auto; - max-height: 40vh; - overflow-y: auto; - transform: none; -} - .gx-mask-btn { font-size: 13px; padding: 8px 18px; @@ -1027,10 +935,24 @@ opacity: 0.72; } +.mwm-neighbor-section { + display: grid; + gap: 6px; + min-width: 0; +} + +.mwm-neighbor-list { + display: grid; + grid-auto-rows: minmax(var(--mwm-link-row-min-height), 1fr); + gap: 6px; + min-width: 0; +} + .mwm-pin-group { display: grid; gap: 6px; margin-top: 8px; + min-width: 0; } .mwm-pin-group-header { @@ -1093,35 +1015,92 @@ cursor: pointer; box-shadow: none; white-space: normal; - overflow-wrap: break-word; + overflow-wrap: anywhere; + word-break: break-word; + box-sizing: border-box; +} + +.mwm-link-row { + display: flex; + align-items: center; + min-height: var(--mwm-link-row-min-height); + padding: 6px 8px; +} + +.mwm-link-row-label { + display: block; + width: 100%; + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; + hyphens: auto; } .mwm-pin-row, .mwm-pin-group-row { - display: flex; - flex-wrap: wrap; - align-items: center; gap: 6px; + min-width: 0; +} + +.mwm-pin-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 6px 8px; +} + +.mwm-pin-group-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.mwm-pin-group-row input { + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; } .mwm-pin-row.is-muted { opacity: 0.58; } -.mwm-pin-row > button:not(.mwm-pin-main), .mwm-pin-group-row > button, -.mwm-pin-group-header > button { - flex: 0 0 auto; +.mwm-pin-group-header > button, +.mwm-pin-actions > button { font-size: 10px; padding: 3px 5px; } +.mwm-pin-check { + margin-top: 14px; +} + .mwm-pin-main { - flex: 1; - flex-basis: 150px; + display: block; + grid-column: 2; min-width: 0; + overflow-x: hidden; white-space: normal; - overflow: hidden; + text-align: left; +} + +.mwm-pin-actions { + grid-column: 2; + display: flex; + flex-wrap: wrap; + gap: 5px; + min-width: 0; +} + +.mwm-pin-actions > button { + flex: 1 1 72px; + min-width: 0; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; } .mwm-pin-title { @@ -1133,9 +1112,19 @@ .mwm-pin-meta { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow-x: hidden; + overflow-y: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: break-word; + word-break: normal; +} + +.mwm-scroll-path { + max-height: 56px; + overflow-x: hidden; + overflow-y: auto; + scrollbar-gutter: stable; } .mwm-floating-controls { diff --git a/versions.json b/versions.json index 21a14ff..34c156d 100644 --- a/versions.json +++ b/versions.json @@ -6,5 +6,6 @@ "0.2.0": "1.5.0", "0.2.1": "1.5.0", "0.2.2": "1.13.0", - "0.2.3": "1.13.0" + "0.2.3": "1.13.0", + "0.2.4": "1.13.0" }