mirror of
https://github.com/miro0o/miniWorldMap.git
synced 2026-07-22 07:46:00 +00:00
Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0a64e8b7 |
12 changed files with 256 additions and 285 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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} 出链' },
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
235
styles.css
235
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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue