refactor: extract View, Modal, and SettingsTab from main.ts

Move ParallelReaderView to src/view.ts (341 lines),
CardEditModal to src/modal.ts (56 lines),
ParallelReaderSettingTab to src/settings-tab.ts (370 lines).
Add PluginHost interface in types.ts to break circular deps.
main.ts reduced from 1613 to 588 lines.

Change-Id: I90902e914f162ff92b9a7f7c190b4d397a2c8c12
This commit is contained in:
wujunchen 2026-04-25 23:40:56 +08:00
parent f3afc4fbe0
commit 532cd31ef4
7 changed files with 937 additions and 1170 deletions

52
main.js

File diff suppressed because one or more lines are too long

1252
main.ts

File diff suppressed because it is too large Load diff

56
src/modal.ts Normal file
View file

@ -0,0 +1,56 @@
'use strict';
import { Modal } from 'obsidian';
import type { ResolvedCard, CardPatch, PluginHost } from './types';
import { addTextButton } from './ui-helpers';
export class CardEditModal extends Modal {
plugin: PluginHost;
card: ResolvedCard;
onSave: (patch: CardPatch) => void | Promise<void>;
constructor(app, plugin: PluginHost, card: ResolvedCard, onSave: (patch: CardPatch) => void | Promise<void>) {
super(app);
this.plugin = plugin;
this.card = card || ({} as ResolvedCard);
this.onSave = onSave;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h2', { text: this.plugin.t('editCardTitle') });
const titleInput = this.createLabeledInput(contentEl, this.plugin.t('editCardTitleField'), this.card.title || '');
const gistInput = this.createLabeledTextarea(contentEl, this.plugin.t('editCardGistField'), this.card.gist || '', 3);
const bulletsInput = this.createLabeledTextarea(contentEl, this.plugin.t('editCardBulletsField'), (this.card.bullets || []).join('\n'), 8);
const actions = contentEl.createDiv({ cls: 'parallel-reader-modal-actions' });
addTextButton(actions, null, this.plugin.t('editCardCancel'), () => this.close(), 'parallel-reader-text-button');
addTextButton(actions, null, this.plugin.t('editCardSave'), async () => {
await this.onSave({
title: titleInput.value.trim() || this.card.title || '',
gist: gistInput.value.trim(),
bullets: bulletsInput.value.split(/\r?\n/).map(line => line.trim()).filter(Boolean),
});
this.close();
}, 'parallel-reader-text-button');
}
createLabeledInput(parent, label: string, value: string) {
const wrapper = parent.createDiv({ cls: 'parallel-reader-modal-field' });
wrapper.createEl('label', { text: label });
const input = wrapper.createEl('input', { attr: { type: 'text' } });
input.value = value;
return input;
}
createLabeledTextarea(parent, label: string, value: string, rows: number) {
const wrapper = parent.createDiv({ cls: 'parallel-reader-modal-field' });
wrapper.createEl('label', { text: label });
const textarea = wrapper.createEl('textarea');
textarea.rows = rows;
textarea.value = value;
return textarea;
}
}

370
src/settings-tab.ts Normal file
View file

@ -0,0 +1,370 @@
'use strict';
import { PluginSettingTab, Setting, Notice, requestUrl } from 'obsidian';
import type { PluginHost } from './types';
import { resolveCliPath, runCli } from './cli';
import { testApiBackend } from './providers';
import {
API_AUTH_TYPES,
API_FORMATS,
API_PROVIDER_PRESETS,
DEFAULT_SETTINGS,
DEFAULT_MAX_CACHE_ENTRIES,
PROMPT_LANGUAGES,
UI_LANGUAGES,
applyApiProviderPreset,
getApiFormat,
getApiPreset,
isApiBackend,
} from './settings';
async function testBackend(settings) {
if (settings.backend === 'codex') {
const cmd = resolveCliPath('codex', settings.cliPath);
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
return `codex @ ${cmd}\n${stdout.trim()}`;
}
if (settings.backend === 'claude-code') {
const cmd = resolveCliPath('claude', settings.cliPath);
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
return `claude @ ${cmd}\n${stdout.trim()}`;
}
if (isApiBackend(settings.backend)) {
return testApiBackend(requestUrl, settings);
}
throw new Error('Unknown backend: ' + settings.backend);
}
export class ParallelReaderSettingTab extends PluginSettingTab {
plugin: PluginHost;
constructor(app, plugin: PluginHost) {
super(app, plugin as any);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
const tr = (key, vars?) => this.plugin.t(key, vars);
containerEl.empty();
containerEl.createEl('h2', { text: tr('settingsTitle') });
new Setting(containerEl)
.setName(tr('settingUiLanguageName'))
.setDesc(tr('settingUiLanguageDesc'))
.addDropdown(d => {
for (const [id, label] of Object.entries(UI_LANGUAGES)) {
d.addOption(id, label);
}
return d
.setValue(this.plugin.settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage)
.onChange(async v => {
this.plugin.settings.uiLanguage = v;
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl)
.setName(tr('settingBackendName'))
.setDesc(tr('settingBackendDesc'))
.addDropdown(d => d
.addOption('claude-code', 'Claude Code CLI')
.addOption('codex', 'Codex CLI')
.addOption('api', 'API / Provider')
.addOption('anthropic-api', 'Anthropic API (legacy)')
.setValue(this.plugin.settings.backend)
.onChange(async v => {
this.plugin.settings.backend = v;
if (v === 'api' && !this.plugin.settings.apiBaseUrl) {
applyApiProviderPreset(this.plugin.settings, this.plugin.settings.apiProvider || 'anthropic');
}
await this.plugin.saveSettings();
this.display();
}));
const apiBackend = isApiBackend(this.plugin.settings.backend);
if (!apiBackend) {
new Setting(containerEl)
.setName(tr('settingCliPathName'))
.setDesc(tr('settingCliPathDesc'))
.addText(t => t
.setPlaceholder(tr('settingCliPathPlaceholder'))
.setValue(this.plugin.settings.cliPath)
.onChange(async v => {
this.plugin.settings.cliPath = v.trim();
this.plugin.saveSettingsDebounced();
}));
new Setting(containerEl)
.setName(tr('settingCliTimeoutName'))
.addText(t => t
.setValue(String(this.plugin.settings.cliTimeoutMs))
.onChange(async v => {
const n = parseInt(v, 10);
if (!isNaN(n) && n > 0) {
this.plugin.settings.cliTimeoutMs = n;
this.plugin.saveSettingsDebounced();
}
}));
} else {
containerEl.createEl('h3', { text: tr('apiProviderHeader') });
const preset = getApiPreset(this.plugin.settings);
new Setting(containerEl)
.setName(tr('settingProviderPresetName'))
.setDesc(tr('settingProviderPresetDesc'))
.addDropdown(d => {
for (const [id, entry] of Object.entries(API_PROVIDER_PRESETS)) {
d.addOption(id, entry.label);
}
return d
.setValue(this.plugin.settings.apiProvider)
.onChange(async v => {
applyApiProviderPreset(this.plugin.settings, v);
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl)
.setName(tr('settingApiFormatName'))
.setDesc(tr('settingApiFormatDesc'))
.addDropdown(d => {
for (const [id, entry] of Object.entries(API_FORMATS)) {
d.addOption(id, entry.label);
}
return d
.setValue(getApiFormat(this.plugin.settings))
.onChange(async v => {
this.plugin.settings.apiFormat = v;
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl)
.setName(tr('settingBaseUrlName'))
.setDesc(tr('settingBaseUrlDesc'))
.addText(t => t
.setPlaceholder(
(this.plugin.settings.apiProvider || '').startsWith('custom-')
? 'https://your-provider.example/v1'
: (preset.baseUrl || API_FORMATS[getApiFormat(this.plugin.settings)].defaultBaseUrl)
)
.setValue(this.plugin.settings.apiBaseUrl)
.onChange(async v => {
this.plugin.settings.apiBaseUrl = v.trim();
this.plugin.saveSettingsDebounced();
}));
new Setting(containerEl)
.setName(tr('settingApiKeyName'))
.setDesc(tr('settingApiKeyDesc'))
.addText(t => {
t.inputEl.type = 'password';
return t
.setPlaceholder('sk-...')
.setValue(this.plugin.settings.apiKey)
.onChange(async v => {
this.plugin.settings.apiKey = v.trim();
this.plugin.saveSettingsDebounced();
});
});
new Setting(containerEl)
.setName(tr('settingApiKeyEnvName'))
.setDesc(tr('settingApiKeyEnvDesc'))
.addText(t => t
.setPlaceholder(preset.envVar || 'OPENAI_API_KEY')
.setValue(this.plugin.settings.apiKeyEnvVar)
.onChange(async v => {
this.plugin.settings.apiKeyEnvVar = v.trim();
this.plugin.saveSettingsDebounced();
}));
new Setting(containerEl)
.setName(tr('settingAuthTypeName'))
.setDesc(tr('settingAuthTypeDesc'))
.addDropdown(d => {
for (const [id, label] of Object.entries(API_AUTH_TYPES)) {
d.addOption(id, label);
}
return d
.setValue(this.plugin.settings.apiAuthType || 'auto')
.onChange(async v => {
this.plugin.settings.apiAuthType = v;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(tr('settingHeadersName'))
.setDesc(tr('settingHeadersDesc'))
.addTextArea(t => t
.setPlaceholder('cf-aig-authorization: Bearer ...')
.setValue(this.plugin.settings.apiHeaders)
.onChange(async v => {
this.plugin.settings.apiHeaders = v;
this.plugin.saveSettingsDebounced();
}));
new Setting(containerEl)
.setName(tr('settingMaxTokensName'))
.addText(t => t
.setValue(String(this.plugin.settings.apiMaxTokens))
.onChange(async v => {
const n = parseInt(v, 10);
if (!isNaN(n) && n > 0) {
this.plugin.settings.apiMaxTokens = n;
this.plugin.saveSettingsDebounced();
}
}));
}
new Setting(containerEl)
.setName(tr('settingModelName'))
.setDesc(apiBackend
? tr('settingModelDescApi')
: tr('settingModelDescCli'))
.addText(t => t
.setPlaceholder(apiBackend ? (getApiPreset(this.plugin.settings).model || 'model-id') : DEFAULT_SETTINGS.model)
.setValue(this.plugin.settings.model)
.onChange(async v => {
this.plugin.settings.model = v.trim() || (apiBackend ? '' : DEFAULT_SETTINGS.model);
this.plugin.saveSettingsDebounced();
}));
new Setting(containerEl)
.setName(tr('settingMaxInputName'))
.setDesc(tr('settingMaxInputDesc'))
.addText(t => t
.setValue(String(this.plugin.settings.maxDocChars || DEFAULT_SETTINGS.maxDocChars))
.onChange(async v => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1000) {
this.plugin.settings.maxDocChars = n;
this.plugin.saveSettingsDebounced();
}
}));
containerEl.createEl('h3', { text: tr('promptHeader') });
new Setting(containerEl)
.setName(tr('settingPromptLanguageName'))
.setDesc(tr('settingPromptLanguageDesc'))
.addDropdown(d => {
for (const [id, label] of Object.entries(PROMPT_LANGUAGES)) {
d.addOption(id, label);
}
return d
.setValue(this.plugin.settings.promptLanguage || DEFAULT_SETTINGS.promptLanguage)
.onChange(async v => {
this.plugin.settings.promptLanguage = v;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(tr('settingCardRangeName'))
.setDesc(tr('settingCardRangeDesc'))
.addText(t => t
.setPlaceholder('min')
.setValue(String(this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards))
.onChange(async v => {
const n = parseInt(v, 10);
if (!isNaN(n) && n > 0) {
this.plugin.settings.minCards = n;
if (this.plugin.settings.maxCards < n) this.plugin.settings.maxCards = n;
this.plugin.saveSettingsDebounced();
}
}))
.addText(t => t
.setPlaceholder('max')
.setValue(String(this.plugin.settings.maxCards || DEFAULT_SETTINGS.maxCards))
.onChange(async v => {
const n = parseInt(v, 10);
if (!isNaN(n) && n > 0) {
this.plugin.settings.maxCards = Math.max(n, this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards);
this.plugin.saveSettingsDebounced();
}
}));
new Setting(containerEl)
.setName(tr('settingCustomPromptName'))
.setDesc(tr('settingCustomPromptDesc'))
.addTextArea(t => {
t.inputEl.rows = 8;
return t
.setPlaceholder(tr('settingCustomPromptPlaceholder'))
.setValue(this.plugin.settings.customSystemPrompt || '')
.onChange(async v => {
this.plugin.settings.customSystemPrompt = v;
this.plugin.saveSettingsDebounced();
});
});
new Setting(containerEl)
.setName(tr('settingTestBackendName'))
.setDesc(apiBackend ? tr('settingTestBackendDescApi') : tr('settingTestBackendDescCli'))
.addButton(b => b
.setButtonText('Test')
.onClick(async () => {
try {
const result = await testBackend(this.plugin.settings);
new Notice(`${result.slice(0, 180)}`, 8000);
} catch (e) {
new Notice(tr('backendTestFailed', { error: e.message }), 10000);
}
}));
new Setting(containerEl)
.setName(tr('settingExportFolderName'))
.setDesc(tr('settingExportFolderDesc'))
.addText(t => t
.setValue(this.plugin.settings.exportFolder)
.onChange(async v => {
this.plugin.settings.exportFolder = v.trim() || DEFAULT_SETTINGS.exportFolder;
this.plugin.saveSettingsDebounced();
}));
containerEl.createEl('h3', { text: tr('cacheHeader') });
new Setting(containerEl)
.setName(tr('settingMaxCacheName'))
.setDesc(tr('settingMaxCacheDesc'))
.addText(t => {
t.setValue(String(this.plugin.settings.maxCacheEntries || DEFAULT_MAX_CACHE_ENTRIES));
const commit = async () => {
const n = parseInt(t.getValue(), 10);
if (Number.isFinite(n) && n > 0) {
this.plugin.settings.maxCacheEntries = n;
await this.plugin.saveSettings();
const removed = await this.plugin.pruneCacheIfNeeded();
if (removed.length > 0) new Notice(tr('cachePruned', { count: removed.length }));
this.display();
}
};
t.inputEl.addEventListener('change', commit);
t.inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter') t.inputEl.blur();
});
return t;
});
const cacheCount = Object.keys(this.plugin.cache).length;
new Setting(containerEl)
.setName(tr('cachedNotesName', { count: cacheCount }))
.setDesc(tr('cachedNotesDesc'))
.addButton(b => b
.setButtonText(tr('clearAllCacheButton'))
.setWarning()
.onClick(async () => {
const n = Object.keys(this.plugin.cache).length;
await this.plugin.cacheClear();
new Notice(tr('cacheClearedAll', { count: n }));
this.display();
}));
}
}

View file

@ -112,3 +112,28 @@ export interface PromptPair {
system: string;
user: string;
}
/* ---------- Plugin host interface ---------- */
/**
* Minimal interface that extracted UI classes (View, Modal, SettingsTab)
* use to call back into the plugin. Avoids circular imports between
* main.ts and the extracted modules.
*/
export interface PluginHost {
app: { vault: any; workspace: any };
settings: PluginSettings;
cache: Record<string, CacheEntry>;
manifest?: { id: string };
t(key: string, vars?: Record<string, string | number>): string;
isGeneratingFile(file: any): boolean;
cancelGenerationForFile(file: any): boolean;
runForFile(file: any, force: boolean): Promise<void>;
copyCurrentViewMarkdown(): Promise<void>;
scrollEditorToLine(line: number, file: any): Promise<void>;
cacheReplaceCards(filePath: string, cards: ResolvedCard[]): Promise<boolean>;
saveSettings(): Promise<void>;
saveSettingsDebounced(delayMs?: number): void;
cacheClear(): Promise<void>;
pruneCacheIfNeeded(): Promise<string[]>;
}

341
src/view.ts Normal file
View file

@ -0,0 +1,341 @@
'use strict';
import { ItemView, Notice, TFile, Menu, MarkdownRenderer } from 'obsidian';
import type { ResolvedCard, CardPatch, PluginHost } from './types';
import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './cards';
import { cardToMarkdown, cardToPlain, cardsToMarkdown } from './markdown';
import { activeSectionLine, nextCardIndex } from './navigation';
import { normalizeVaultPath } from './vault';
import { ensureVaultFolder } from './vault';
import { addIconButton, addTextButton, copyToClipboard } from './ui-helpers';
import { CardEditModal } from './modal';
export const VIEW_TYPE_PARALLEL = 'parallel-reader-view';
export class ParallelReaderView extends ItemView {
plugin: PluginHost;
sections: ResolvedCard[];
sourceFile: TFile | null;
cards: HTMLElement[];
activeIdx: number;
stale: boolean;
loadingMessage: string;
errorMessage: string;
constructor(leaf, plugin: PluginHost) {
super(leaf);
this.plugin = plugin;
this.sections = [];
this.sourceFile = null;
this.cards = [];
this.activeIdx = -1;
this.loadingMessage = '';
this.errorMessage = '';
}
getViewType() { return VIEW_TYPE_PARALLEL; }
getDisplayText() { return this.plugin.t('displayName'); }
getIcon() { return 'book-open'; }
onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass('parallel-reader-container');
container.setAttr('tabindex', '0');
container.addEventListener('keydown', e => this.handleKeydown(e as KeyboardEvent));
this.renderEmpty();
this.focusSummaryPane();
return Promise.resolve();
}
onClose() {
return Promise.resolve();
}
renderEmpty() {
this.sourceFile = null;
this.sections = [];
this.stale = false;
this.loadingMessage = '';
this.errorMessage = '';
const container = this.containerEl.children[1];
container.empty();
const hint = container.createDiv({ cls: 'parallel-reader-empty' });
hint.createEl('h3', { text: this.plugin.t('appTitle') });
hint.createEl('p', { text: this.plugin.t('emptyOpenNote') });
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
}
focusSummaryPane() {
const container = this.containerEl.children[1] as HTMLElement;
if (!container || typeof container.focus !== 'function') return false;
container.focus({ preventScroll: true });
return true;
}
async loadFor(file, sections, stale) {
this.sourceFile = file;
this.sections = sections;
this.stale = !!stale;
this.loadingMessage = '';
this.errorMessage = '';
this.render();
}
async renderLoading(file, message) {
this.sourceFile = file;
this.sections = [];
this.stale = false;
this.loadingMessage = message || this.plugin.t('loadingDefault');
this.errorMessage = '';
this.render();
}
async renderError(file, message) {
this.sourceFile = file;
this.sections = [];
this.stale = false;
this.loadingMessage = '';
this.errorMessage = message || this.plugin.t('errorTitle');
this.render();
}
renderEmptyWithHint(file) {
this.sourceFile = file;
this.sections = [];
this.stale = false;
this.loadingMessage = '';
this.errorMessage = '';
const container = this.containerEl.children[1];
container.empty();
const hint = container.createDiv({ cls: 'parallel-reader-empty' });
hint.createEl('h3', { text: file.basename });
hint.createEl('p', { text: this.plugin.t('emptyNoCache') });
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
}
render() {
const container = this.containerEl.children[1];
container.empty();
const header = container.createDiv({ cls: 'parallel-reader-header' });
const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' });
headerRow.createEl('div', { text: this.sourceFile?.basename || '', cls: 'parallel-reader-title' });
const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' });
if (this.sourceFile) {
if (this.plugin.isGeneratingFile(this.sourceFile)) {
addIconButton(actions, 'square', this.plugin.t('actionCancel'), () => this.plugin.cancelGenerationForFile(this.sourceFile));
} else {
addIconButton(actions, 'refresh-cw', this.plugin.t('actionRegenerate'), () => this.plugin.runForFile(this.sourceFile, true));
}
addIconButton(actions, 'copy', this.plugin.t('actionCopyAll'), () => this.plugin.copyCurrentViewMarkdown());
addIconButton(actions, 'download', this.plugin.t('actionExport'), () => this.exportToVault());
}
if (this.stale) {
const banner = container.createDiv({ cls: 'parallel-reader-stale-banner' });
banner.createSpan({ text: this.plugin.t('staleBanner') });
addTextButton(
banner,
'refresh-cw',
this.plugin.t('actionRegenerate'),
() => this.plugin.runForFile(this.sourceFile, true),
'parallel-reader-stale-button'
);
}
if (this.loadingMessage) {
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-loading' });
state.createDiv({ cls: 'parallel-reader-spinner' });
state.createEl('div', { text: this.loadingMessage, cls: 'parallel-reader-state-title' });
state.createEl('div', { text: this.plugin.t('loadingSubtitle'), cls: 'parallel-reader-state-subtitle' });
return;
}
if (this.errorMessage) {
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-error' });
state.createEl('div', { text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' });
state.createEl('div', { text: this.errorMessage, cls: 'parallel-reader-state-subtitle' });
addTextButton(
state,
'refresh-cw',
this.plugin.t('actionRegenerate'),
() => this.plugin.runForFile(this.sourceFile, true),
'parallel-reader-text-button'
);
return;
}
const list = container.createDiv({ cls: 'parallel-reader-cards' });
this.cards = [];
const sourcePath = this.sourceFile?.path || '';
this.sections.forEach((s, i) => {
const card = list.createDiv({ cls: 'parallel-reader-card' });
card.dataset.idx = String(i);
if (s.startLine < 0) card.addClass('parallel-reader-card-unanchored');
const title = card.createEl('div', { cls: 'parallel-reader-card-title' });
title.createSpan({ text: s.title });
if (s.startLine < 0) {
title.createEl('span', { text: ' ⚠', cls: 'parallel-reader-warn', title: this.plugin.t('anchorMismatch') });
}
if (s.gist) {
const gistEl = card.createEl('div', { cls: 'parallel-reader-gist' });
MarkdownRenderer.render(this.app, s.gist, gistEl, sourcePath, this).catch(() => {
gistEl.setText(s.gist);
});
}
const bs = s.bullets || [];
if (bs.length > 0) {
const bulletsEl = card.createEl('div', { cls: 'parallel-reader-bullets-md' });
const md = bs.map(b => `- ${b}`).join('\n');
MarkdownRenderer.render(this.app, md, bulletsEl, sourcePath, this).catch(() => {
bulletsEl.setText(md);
});
} else if (!s.gist) {
card.createEl('div', { cls: 'parallel-reader-empty-li', text: this.plugin.t('emptyCard') });
}
card.addEventListener('click', (e) => {
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
const target = e.target as HTMLElement | null;
if (target && target.tagName === 'A') return;
if (s.startLine >= 0) this.plugin.scrollEditorToLine(s.startLine, this.sourceFile);
});
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
const menu = new Menu();
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyMarkdown')).setIcon('copy')
.onClick(() => copyToClipboard(cardToMarkdown(s), this.plugin.t('copiedMarkdown'))));
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyPlain')).setIcon('clipboard-copy')
.onClick(() => copyToClipboard(cardToPlain(s), this.plugin.t('copiedPlain'))));
if (s.anchor) {
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyAnchor')).setIcon('quote-glyph')
.onClick(() => copyToClipboard(s.anchor, this.plugin.t('copiedAnchor'))));
}
menu.addSeparator();
if (s.startLine >= 0) {
menu.addItem(it => it.setTitle(this.plugin.t('menuJumpSource')).setIcon('arrow-right')
.onClick(() => this.plugin.scrollEditorToLine(s.startLine, this.sourceFile)));
}
menu.addSeparator();
menu.addItem(it => it.setTitle(this.plugin.t('menuEditCard')).setIcon('pencil')
.onClick(() => this.openEditCardModal(i)));
menu.addItem(it => it.setTitle(this.plugin.t('menuDeleteCard')).setIcon('trash')
.onClick(() => this.deleteCard(i)));
menu.showAtMouseEvent(e);
});
this.cards.push(card);
});
if (this.activeIdx >= 0 && this.cards[this.activeIdx]) {
this.cards[this.activeIdx].addClass('is-active');
}
}
setActiveSection(idx) {
if (idx === this.activeIdx) return;
if (this.activeIdx >= 0 && this.cards[this.activeIdx]) {
this.cards[this.activeIdx].removeClass('is-active');
}
this.activeIdx = idx;
if (idx >= 0 && this.cards[idx]) {
this.cards[idx].addClass('is-active');
this.cards[idx].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
moveActiveSection(delta) {
const nextIdx = nextCardIndex(this.activeIdx, this.sections.length, delta);
this.setActiveSection(nextIdx);
this.focusSummaryPane();
return nextIdx;
}
jumpToActiveSection() {
const line = activeSectionLine(this.sections, this.activeIdx);
if (line < 0 || !this.sourceFile) return -1;
this.plugin.scrollEditorToLine(line, this.sourceFile);
return line;
}
handleKeydown(e: KeyboardEvent) {
if (e.altKey && e.key === 'ArrowUp') {
e.preventDefault();
this.moveActiveSection(-1);
return;
}
if (e.altKey && e.key === 'ArrowDown') {
e.preventDefault();
this.moveActiveSection(1);
return;
}
if (!e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.key === 'Enter') {
const line = this.jumpToActiveSection();
if (line >= 0) e.preventDefault();
}
}
async deleteCard(index) {
if (!this.sourceFile) return false;
const nextSections = removeCardAt(this.sections, index);
if (nextSections.length === this.sections.length) return false;
const previousLength = this.sections.length;
this.sections = nextSections;
this.activeIdx = activeIndexAfterCardDelete(index, previousLength, this.activeIdx);
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
this.render();
new Notice(this.plugin.t('cardDeleted'));
return true;
}
openEditCardModal(index) {
if (!this.sourceFile || !this.sections[index]) return false;
new CardEditModal(this.app, this.plugin, this.sections[index], async patch => { await this.updateCard(index, patch); }).open();
return true;
}
async updateCard(index, patch: CardPatch) {
if (!this.sourceFile) return false;
const nextSections = updateCardAt(this.sections, index, patch);
if (nextSections.length !== this.sections.length) return false;
this.sections = nextSections;
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
this.render();
new Notice(this.plugin.t('cardSaved'));
return true;
}
async exportToVault() {
if (!this.sourceFile) return;
const folder = normalizeVaultPath(this.plugin.settings.exportFolder);
const name = `${this.sourceFile.basename} - ${this.plugin.t('displayName')}.md`;
const targetPath = `${folder}/${name}`;
const markdown = [
'---',
`source: [[${this.sourceFile.basename}]]`,
`generated: ${new Date().toISOString().slice(0, 10)}`,
'tool: parallel-reader',
'---',
'',
cardsToMarkdown(`${this.sourceFile.basename} · ${this.plugin.t('displayName')}`, this.sections),
'',
].join('\n');
const app = this.plugin.app;
await ensureVaultFolder(app, folder);
const existing = app.vault.getAbstractFileByPath(targetPath);
if (existing instanceof TFile) {
await app.vault.modify(existing, markdown);
} else {
await app.vault.create(targetPath, markdown);
}
new Notice(this.plugin.t('exported', { path: targetPath }));
}
}

View file

@ -44,11 +44,12 @@ const t = plugin.__test;
assert.ok(t, 'test helpers should be exported');
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.ts'), 'utf8');
assert.ok(!/\basync\s+onOpen\s*\(/.test(mainSource), 'ParallelReaderView.onOpen should not be async without await');
assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(mainSource), 'empty onClose should not be async');
assert.ok(/focusSummaryPane\s*\(\)/.test(mainSource), 'summary pane should expose a focus helper');
assert.ok(/\.focus\(\{\s*preventScroll:\s*true\s*\}\)/.test(mainSource), 'summary pane focus should not scroll the page');
assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(mainSource), 'card navigation should focus the summary pane');
const viewSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'view.ts'), 'utf8');
assert.ok(!/\basync\s+onOpen\s*\(/.test(viewSource), 'ParallelReaderView.onOpen should not be async without await');
assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(viewSource), 'empty onClose should not be async');
assert.ok(/focusSummaryPane\s*\(\)/.test(viewSource), 'summary pane should expose a focus helper');
assert.ok(/\.focus\(\{\s*preventScroll:\s*true\s*\}\)/.test(viewSource), 'summary pane focus should not scroll the page');
assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(viewSource), 'card navigation should focus the summary pane');
assert.ok(/scheduleCacheSave\s*\(/.test(mainSource), 'cache touch should use a debounced cache save path');
assert.ok(/flushCacheSave\s*\(/.test(mainSource), 'pending cache touches should be flushable');
assert.ok(/onunload[\s\S]*flushCacheSave/.test(mainSource), 'plugin unload should flush pending cache touches');