Release 0.2.4

This commit is contained in:
Kur0mi 2026-06-25 10:26:21 +02:00
parent 91d48e372a
commit 4c0a64e8b7
12 changed files with 256 additions and 285 deletions

View file

@ -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.

View file

@ -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",

4
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -59,7 +59,7 @@ const STRINGS: Record<string, Record<Language, string>> = {
'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<string, Record<Language, string>> = {
'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<string, Record<Language, string>> = {
'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} 出链' },

View file

@ -27,7 +27,7 @@ export default class MiniWorldMapPlugin extends Plugin {
async onload(): Promise<void> {
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));

View file

@ -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');

View file

@ -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<number>): 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, string | number> = {}): 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();
}

View file

@ -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);
});

View file

@ -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;

View file

@ -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 {

View file

@ -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"
}