feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features

- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio),
  summarize/improve/translate commands, proofread via ai:analyze; calls
  proxied through main so API keys stay out of the renderer
- Collaboration plugin: anchor-based comments in .comments/ sidecars with
  drift detection and F8 navigation
- Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel
- Crash recovery: debounced session snapshots with restore prompt on launch
- Version history: pre-save snapshots, History panel with restore/diff/delete
- Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI)
- XLSX export (native workbooks via JSZip), ODT headers/footers + page size
- Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering
- Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter,
  writing heatmap, writing-studio panels wired with rail icons
- Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links,
  REPL first-run confirmation
- Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI
  dangling --css/--reference-doc flags, dead converter button

8 new test suites; 613 tests green; lint clean
This commit is contained in:
2026-09-05 20:48:39 +05:30
parent b83ba91731
commit c4dcbd8caf
105 changed files with 5695 additions and 195 deletions
+538 -51
View File
@@ -17,6 +17,46 @@ const { showDocumentCompareDialog } = require('./renderer/document-compare-dialo
const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets');
const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table');
const { getFilePath } = require('./utils/file-path');
const SessionStore = require('./utils/session-store');
const { renderWikiLinksInHtml, resolveTargetPath } = require('./utils/wiki-links');
const fs = require('fs');
// ================================
// Snippet Tab-expansion registry
// ================================
// A lowercase-name → content map rebuilt whenever snippets change; queried
// synchronously by every editor's Tab keymap (see snippetTabKeymap).
const snippetExpansions = new Map();
async function refreshSnippetExpansions() {
try {
const snippets = await ipcRenderer.invoke('get-snippets');
snippetExpansions.clear();
for (const snippet of Array.isArray(snippets) ? snippets : []) {
if (snippet?.name && typeof snippet.content === 'string') {
snippetExpansions.set(snippet.name.toLowerCase(), snippet.content);
}
}
} catch {
/* snippets unavailable — Tab keeps its default indent behavior */
}
}
refreshSnippetExpansions();
// ================================
// Vim keybinding mode
// ================================
// Mirrors the monospace-setting flow: main menu checkbox → persisted setting
// → renderer applies setVimMode to every live editor. window.__vimModeEnabled
// seeds newly created editors with the right mode.
function applyVimMode(enabled) {
window.__vimModeEnabled = enabled === true;
const { setVimMode } = require('./editor/codemirror-setup');
if (tabManager) {
for (const tab of tabManager.tabs.values()) {
if (tab.editorView) setVimMode(tab.editorView, window.__vimModeEnabled);
}
}
}
/**
* Toggle body classes that drive the monospace font + ligatures CSS tokens.
@@ -278,6 +318,10 @@ marked.use({
],
});
// Session-scoped set of languages the user has approved for REPL execution
// (populated by the confirm dialog on first Run click per language).
const replExecutionConfirmed = new Set();
// PlantUML hex encoding for server rendering
function plantumlEncode(text) {
const hex = Array.from(new TextEncoder().encode(text))
@@ -286,6 +330,67 @@ function plantumlEncode(text) {
return '~h' + hex;
}
// ================================
// PlantUML rendering (local-first, server fallback)
// ================================
// A local `plantuml` CLI (if installed) renders via the main process, so
// diagram text never leaves the machine; otherwise the public plantuml.com
// server is used exactly as before. The availability probe is cached per
// session so each preview render costs at most one invoke.
let plantumlLocalAvailability = undefined; // undefined = not probed yet
/**
* Replace PlantUML code blocks with rendered diagrams.
* @param {NodeList} blocks `pre code.language-plantuml` elements
*/
async function renderPlantUmlBlocks(blocks) {
if (plantumlLocalAvailability === undefined) {
try {
plantumlLocalAvailability = (await ipcRenderer.invoke('plantuml:available')) === true;
} catch {
plantumlLocalAvailability = false;
}
}
for (const block of blocks) {
const code = block.textContent;
const pre = block.parentElement;
if (plantumlLocalAvailability) {
try {
const result = await ipcRenderer.invoke('plantuml:render', { text: code });
if (result?.dataUrl) {
replacePreWithImage(pre, result.dataUrl);
continue;
}
} catch {
// fall through to server rendering
}
}
const img = document.createElement('img');
img.src = `https://www.plantuml.com/plantuml/svg/${plantumlEncode(code)}`;
img.alt = 'PlantUML diagram';
img.style.maxWidth = '100%';
img.onerror = () => {
img.replaceWith(pre); // Fallback to code block on error
};
if (pre.parentElement) pre.parentElement.replaceChild(img, pre);
}
}
/** Swap a <pre> for a rendered <img>, keeping a fallback on error. */
function replacePreWithImage(pre, dataUrl) {
const img = document.createElement('img');
img.src = dataUrl;
img.alt = 'PlantUML diagram';
img.style.maxWidth = '100%';
img.onerror = () => {
img.replaceWith(pre);
};
if (pre.parentElement) pre.parentElement.replaceChild(img, pre);
}
// Scopes user custom CSS by prefixing all selectors to protect application UI
function scopeCSS(cssText, scopeSelector) {
if (!cssText) return '';
@@ -618,6 +723,10 @@ class TabManager {
const isDark = document.body.className.includes('dark');
tab.editorView = createEditor(editorContainer, {
content: tab.content,
// Vim mode follows the persisted app setting for every new editor
vimMode: window.__vimModeEnabled === true,
// Tab expands a snippet when the word before the cursor matches one
getTabExpansion: (prefix) => snippetExpansions.get(prefix) || null,
onChange: (newContent) => {
tab.content = newContent;
tab.isDirty = true;
@@ -638,6 +747,7 @@ class TabManager {
this.updatePreview(tab.id);
this.updateWordCount();
this.updateTabBar();
this.scheduleSessionPersist(); // crash-recovery snapshot (debounced)
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
},
onUpdate: (view) => {
@@ -678,6 +788,7 @@ class TabManager {
// Refresh outline panel for new tab content
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
this.scheduleSessionPersist(); // crash-recovery snapshot
}
switchToNextTab() {
const tabIds = Array.from(this.tabs.keys());
@@ -729,6 +840,7 @@ class TabManager {
this.switchToTab(remainingTabs[0]);
}
this.updateTabBar();
this.scheduleSessionPersist(); // crash-recovery snapshot
}
updateTabBar() {
const tabBar = document.getElementById('tab-bar');
@@ -919,6 +1031,13 @@ class TabManager {
}
preview.innerHTML = sanitizedHtml;
// Turn [[wiki links]] into clickable links (knowledge-base mode).
// Applied after sanitization; renderWikiLinksInHtml itself escapes the
// target/label text, so no new HTML injection surface is opened.
if (sanitizedHtml.indexOf('[[') !== -1) {
preview.innerHTML = renderWikiLinksInHtml(sanitizedHtml);
}
// Render math expressions if KaTeX is available
if (window.katex && window.renderMathInElement) {
try {
@@ -989,23 +1108,13 @@ class TabManager {
}
}
// Render PlantUML diagrams
// Render PlantUML diagrams — locally when a plantuml CLI is installed
// (keeps diagram sources on the machine), falling back to the public
// server otherwise. Local availability is checked once per session.
const plantumlBlocks = preview.querySelectorAll('pre code.language-plantuml');
plantumlBlocks.forEach((block) => {
const code = block.textContent;
const pre = block.parentElement;
// Encode for PlantUML server using hex encoding
const encoded = plantumlEncode(code);
const img = document.createElement('img');
img.src = `https://www.plantuml.com/plantuml/svg/${encoded}`;
img.alt = 'PlantUML diagram';
img.style.maxWidth = '100%';
img.onerror = () => {
img.replaceWith(pre); // Fallback to code block on error
};
pre.parentElement.replaceChild(img, pre);
});
if (plantumlBlocks.length > 0) {
renderPlantUmlBlocks(plantumlBlocks);
}
// Add Run buttons to executable code blocks (REPL)
const codeBlocks = preview.querySelectorAll('pre code[class*="language-"]');
@@ -1017,6 +1126,18 @@ class TabManager {
runBtn.textContent = '\u25B6 Run';
runBtn.addEventListener('click', async () => {
if (replPanel) {
// Code execution runs UNSANDBOXED on the user's machine
// (node/python/shell child processes). Confirm once per session
// per language so a crafted document can't silently run code
// the first time its Run button is clicked.
if (!replExecutionConfirmed.has(lang)) {
const ok = confirm(
`Run ${lang} code blocks on this machine?\n\n` +
'Code from the document will execute as a child process with your user permissions (10s timeout).'
);
if (!ok) return;
replExecutionConfirmed.add(lang);
}
replPanel.show();
const result = await ipcRenderer.invoke('execute-code', {
code: block.textContent,
@@ -1156,6 +1277,115 @@ class TabManager {
}, 1500);
}
// ================================
// Session persistence (crash recovery)
// ================================
/**
* Write a session snapshot (open tabs + unsaved content) to localStorage so
* the next launch can offer to restore after a crash or force-quit.
*/
persistSession() {
const session = SessionStore.captureSession(this.tabs, this.activeTabId, (tabId) =>
this.getEditorContent(tabId)
);
SessionStore.saveSession(localStorage, session);
}
/**
* Debounced persistSession — called on every content keystroke, so writing
* to localStorage synchronously each time would be wasteful.
*/
scheduleSessionPersist() {
if (this._sessionPersistTimer) clearTimeout(this._sessionPersistTimer);
this._sessionPersistTimer = setTimeout(() => {
this._sessionPersistTimer = null;
this.persistSession();
}, 2000);
}
/**
* Restore tabs from a saved session snapshot. Returns the number of tabs
* actually restored (missing files and empty tabs are skipped).
*
* @param {object} session Snapshot from SessionStore.loadSession()
* @returns {number} restored tab count
*/
restoreSession(session) {
if (!session || !Array.isArray(session.tabs)) return 0;
let restored = 0;
for (const entry of session.tabs) {
try {
if (entry.type === 'pdf') {
// PDF tabs are view-only; reopen from the original path when it
// still exists, otherwise skip silently.
if (entry.filePath && fs.existsSync(entry.filePath)) {
this.createPdfTab(entry.filePath);
restored++;
}
continue;
}
if (entry.filePath && fs.existsSync(entry.filePath)) {
// File-backed tab: reload the file from disk, then reapply the
// unsaved buffer on top when the snapshot captured one.
const onDisk = fs.readFileSync(entry.filePath, 'utf-8');
this.openFile(entry.filePath, onDisk);
if (entry.isDirty && typeof entry.content === 'string') {
const tab = this.tabs.get(this.activeTabId);
if (tab) {
this.setEditorContent(tab.id, entry.content);
tab.isDirty = true;
this.updateTabBar();
}
}
restored++;
} else if (typeof entry.content === 'string' && entry.content.length > 0) {
// Untitled dirty buffer — the only copy is the snapshot itself.
this.createNewTab();
const tab = this.tabs.get(this.activeTabId);
if (tab) {
this.setEditorContent(tab.id, entry.content);
tab.title = entry.title || 'Untitled';
tab.isDirty = true;
this.updatePreview(tab.id);
this.updateTabBar();
}
restored++;
}
} catch (error) {
console.warn('Failed to restore tab from session:', error);
}
}
// End on the tab that was active when the session was saved, if possible
const lastEntry = [...session.tabs].reverse().find((t) => this._findTabIdForEntry(t) !== null);
const targetId = lastEntry ? this._findTabIdForEntry(lastEntry) : null;
if (targetId !== null && this.tabs.has(targetId)) {
this.switchToTab(targetId);
}
if (restored > 0) this.scheduleSessionPersist();
return restored;
}
/**
* Best-effort mapping of a session entry back to a live tab id after a
* restore pass (matches by filePath, falling back to title for untitled).
*/
_findTabIdForEntry(entry) {
if (!entry) return null;
for (const tab of this.tabs.values()) {
if (entry.filePath && tab.filePath === entry.filePath) return tab.id;
}
for (const tab of this.tabs.values()) {
if (!entry.filePath && !tab.filePath && tab.title === (entry.title || 'Untitled')) {
return tab.id;
}
}
return null;
}
// Recent files functionality
addToRecentFiles(filePath) {
if (!filePath) return;
@@ -1608,6 +1838,7 @@ class TabManager {
}
this.startAutoSave();
this.addToRecentFiles(filePath);
this.scheduleSessionPersist(); // crash-recovery snapshot
this.updateTabBar();
this.updateFilePath();
this.updateBreadcrumb();
@@ -1708,10 +1939,83 @@ async function loadTemplateIntoNewTab(file) {
}
}
/**
* Crash recovery: build and show a lightweight "restore session?" prompt.
* Rendered as a fixed overlay (not a ModalManager dialog) because modals are
* initialized inside DOMContentLoaded and this must appear above everything on
* the very first paint. Disappears on either choice; no opinion is recorded
* for future launches (the prompt appears whenever a snapshot exists).
*
* @param {object} session Snapshot from SessionStore.loadSession()
*/
function showRestoreSessionPrompt(session) {
const unsaved = session.tabs.filter((t) => t.isDirty && typeof t.content === 'string');
const summary =
`${session.tabs.length} tab${session.tabs.length === 1 ? '' : 's'}` +
(unsaved.length > 0 ? `, ${unsaved.length} with unsaved changes` : '');
const overlay = document.createElement('div');
overlay.id = 'session-restore-prompt';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'session-restore-title');
overlay.style.cssText =
'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);' +
'display:flex;align-items:center;justify-content:center;z-index:10000;';
overlay.innerHTML = `
<div style="background:var(--bg-primary,#fff);color:var(--text-primary,#222);border-radius:8px;
padding:24px;max-width:420px;box-shadow:0 8px 32px rgba(0,0,0,0.3);text-align:center;">
<h3 id="session-restore-title" style="margin:0 0 8px;">Restore previous session?</h3>
<p style="margin:0 0 20px;color:var(--text-secondary,#666);">
The app closed with ${summary}. Restore them now?
</p>
<div style="display:flex;gap:12px;justify-content:center;">
<button id="session-restore-yes" type="button"
style="padding:8px 20px;border-radius:4px;border:none;cursor:pointer;
background:#4a90d9;color:#fff;">Restore session</button>
<button id="session-restore-no" type="button"
style="padding:8px 20px;border-radius:4px;border:1px solid #ccc;cursor:pointer;
background:transparent;color:inherit;">Start fresh</button>
</div>
</div>`;
document.body.appendChild(overlay);
const dismiss = () => overlay.remove();
overlay.querySelector('#session-restore-yes').addEventListener('click', () => {
dismiss();
const restored = tabManager ? tabManager.restoreSession(session) : 0;
notifyUser(restored > 0 ? `Restored ${restored} tab(s)` : 'Nothing to restore', 'success');
});
overlay.querySelector('#session-restore-no').addEventListener('click', () => {
dismiss();
SessionStore.clearSession(localStorage);
});
}
document.addEventListener('DOMContentLoaded', async () => {
const ModalManager = _ModalManager;
tabManager = new TabManager();
// ================================
// Crash recovery: offer to restore the previous session
// ================================
// A snapshot is written on edits/tab changes (debounced), on unload, and
// once a minute. It is kept only when it contains something restorable;
// a clean single-empty-tab state clears it.
const previousSession = SessionStore.loadSession(localStorage);
if (previousSession) {
showRestoreSessionPrompt(previousSession);
}
// Persist synchronously on unload — the debounced timer may not have fired
window.addEventListener('beforeunload', () => {
if (tabManager) tabManager.persistSession();
});
// Periodic safety net: guarantees a snapshot at most 60s old even when the
// user steps away mid-edit (laptop sleep, SIGKILL, power loss, etc.)
setInterval(() => {
if (tabManager) tabManager.persistSession();
}, 60000);
// Load saved Custom Preview CSS if present
const savedCSSContent = localStorage.getItem('customPreviewCSSContent');
if (savedCSSContent) {
@@ -1805,8 +2109,17 @@ document.addEventListener('DOMContentLoaded', async () => {
render: (container) =>
getRenderSnippetsPanel()(container, {
getSnippets: () => ipcRenderer.invoke('get-snippets'),
saveSnippet: (s) => ipcRenderer.invoke('save-snippet', s),
deleteSnippet: (id) => ipcRenderer.invoke('delete-snippet', id),
// Wrap save/delete so the Tab-expansion map stays in sync
saveSnippet: async (s) => {
const result = await ipcRenderer.invoke('save-snippet', s);
refreshSnippetExpansions();
return result;
},
deleteSnippet: async (id) => {
const result = await ipcRenderer.invoke('delete-snippet', id);
refreshSnippetExpansions();
return result;
},
onInsert: (code) => tabManager.insertAtCursor(code),
}),
});
@@ -1846,6 +2159,79 @@ document.addEventListener('DOMContentLoaded', async () => {
},
});
// Backlinks panel — "what links here?" for [[wiki links]] (knowledge-base mode)
const { renderBacklinksPanel } = require('./sidebar/backlinks-panel');
sidebarManager.registerPanel('backlinks', {
title: 'Backlinks',
render: (container) =>
renderBacklinksPanel(container, {
getCurrentFilePath: () => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
return tab?.filePath || null;
},
// Bounded folder scan through the same IPC helper the Explorer uses
listDir: (dir) => ipcRenderer.invoke('list-directory', dir),
readFile: (p) => ipcRenderer.invoke('read-file', p),
onFileOpen: (p) => ipcRenderer.send('open-file-path', p),
pathUtil: require('path'),
}),
});
// History panel — local version history (snapshots captured before saves)
const { renderHistoryPanel } = require('./sidebar/history-panel');
sidebarManager.registerPanel('history', {
title: 'History',
render: (container) =>
renderHistoryPanel(container, {
getCurrentFilePath: () => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
return tab?.filePath || null;
},
getCurrentContent: () => tabManager.getEditorContent(),
replaceContent: (content) => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (!tab) return;
tabManager.setEditorContent(tab.id, content);
tab.isDirty = true;
tabManager.updatePreview(tab.id);
tabManager.updateTabBar();
},
history: {
list: (docPath) => ipcRenderer.invoke('version-history:list', docPath),
read: (docPath, id) => ipcRenderer.invoke('version-history:read', { docPath, id }),
save: (docPath, content, label) =>
ipcRenderer.invoke('version-history:save', { docPath, content, label }),
delete: (docPath, id) => ipcRenderer.invoke('version-history:delete', { docPath, id }),
},
}),
});
// Wiki-link navigation: open the target note (creating it on demand after
// confirmation). Delegated on document so it works in every tab's preview.
document.addEventListener('click', (event) => {
const link = event.target.closest?.('.wiki-link');
if (!link) return;
event.preventDefault();
const target = link.getAttribute('data-wiki-target');
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (!target || !tab?.filePath) return;
const pathUtil = require('path');
const targetPath = resolveTargetPath(target, pathUtil.dirname(tab.filePath), pathUtil);
if (!targetPath) return;
if (fs.existsSync(targetPath)) {
ipcRenderer.send('open-file-path', targetPath);
} else if (confirm(`"${target}" doesn't exist yet. Create it?`)) {
// Create then open — the note materializes with a title heading
ipcRenderer
.invoke('write-file', { path: targetPath, content: `# ${target}\n\n` })
.then(() => {
ipcRenderer.send('open-file-path', targetPath);
});
}
});
// View menu: toggle a sidebar panel by id (explorer/git/snippets/templates/outline)
ipcRenderer.on('toggle-sidebar-panel', (event, panelId) => {
sidebarManager.togglePanel(panelId);
@@ -1899,6 +2285,28 @@ document.addEventListener('DOMContentLoaded', async () => {
getSelection: () => tabManager.getSelection(),
insertAtCursor: (text) => tabManager.insertAtCursor(text),
onContentChanged: (cb) => pluginEventBus.on('document:changed', cb),
// Current file path for plugins that store per-file sidecar data
getCurrentFilePath: () => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
return tab?.filePath || null;
},
// Jump the active editor to a 1-based line (used by comment navigation)
jumpToLine: (line) => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (!tab?.editorView || !Number.isFinite(line) || line < 1) return;
const maxLine = tab.editorView.state.doc.lines;
const target = Math.min(Math.floor(line), maxLine);
tab.editorView.dispatch({
selection: { anchor: tab.editorView.state.doc.line(target).from },
scrollIntoView: true,
});
tab.editorView.focus();
},
getCurrentLine: () => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (!tab?.editorView) return 1;
return tab.editorView.state.doc.lineAt(tab.editorView.state.selection.main.head).number;
},
},
ipc: {
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
@@ -1911,6 +2319,66 @@ document.addEventListener('DOMContentLoaded', async () => {
const discovered = loader.discoverPlugins();
discovered.forEach((p) => pluginRegistry.register(p));
// Plugin panels request programmatic opening via the shared event bus
// (e.g. the AI Chat command). Expand the named sidebar panel in response.
pluginEventBus.on('sidebar:open-panel', ({ panel } = {}) => {
if (panel && typeof panel === 'string') sidebarManager.expand(panel);
});
// Wire the writing-studio plugin's sidebar panels (declared in its manifest
// but rendered by these panel modules): registering through the registry's
// context namespaces the ids to "writing-studio:<panel>" and adds rail icons.
const studioPlugin = pluginRegistry.getPlugin('writing-studio');
if (studioPlugin?.context) {
const ctx = studioPlugin.context;
const {
renderManuscriptPanel,
} = require('./plugins/built-in/writing-studio/panels/manuscript-panel');
const { renderGoalsPanel } = require('./plugins/built-in/writing-studio/panels/goals-panel');
const {
renderSnapshotsPanel,
} = require('./plugins/built-in/writing-studio/panels/snapshots-panel');
const {
renderProofreadPanel,
} = require('./plugins/built-in/writing-studio/panels/proofread-panel');
const studioIcon = (paths) =>
`<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
// Panels take {engines, settings, editor, events} — engines is the
// writing-studio plugin's engine map (sprint/goals/snapshots/projects).
const engines = studioPlugin.instance.getEngines ? studioPlugin.instance.getEngines() : {};
const panelDeps = { engines, settings: ctx.settings, editor: ctx.editor, events: ctx.events };
ctx.sidebar.registerPanel('manuscript', {
title: 'Manuscript',
icon: studioIcon(
'<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>'
),
render: (container) => renderManuscriptPanel(container, panelDeps),
});
ctx.sidebar.registerPanel('goals', {
title: 'Goals',
icon: studioIcon(
'<circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="6"></circle><circle cx="12" cy="12" r="2"></circle>'
),
render: (container) => renderGoalsPanel(container, panelDeps),
});
ctx.sidebar.registerPanel('snapshots', {
title: 'Snapshots',
icon: studioIcon(
'<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>'
),
render: (container) => renderSnapshotsPanel(container, panelDeps),
});
ctx.sidebar.registerPanel('proofread', {
title: 'Proofread',
icon: studioIcon(
'<path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>'
),
render: (container) => renderProofreadPanel(container, panelDeps),
});
}
// Tell the main process which export formats plugins registered, so the
// Export menu (built in main.js, a separate process from this one) can
// grow entries for them. Only serializable metadata crosses the IPC
@@ -1975,6 +2443,18 @@ document.addEventListener('DOMContentLoaded', async () => {
.catch(() => {});
});
// Vim mode: initial value from settings, then live toggles from the View menu
ipcRenderer
.invoke('get-vim-mode')
.then(applyVimMode)
.catch(() => {});
ipcRenderer.on('vim-setting-change', (event, enabled) => {
ipcRenderer
.invoke('set-vim-mode', enabled === true)
.then(() => applyVimMode(enabled === true))
.catch(() => {});
});
// Welcome tab on startup
const hasLaunched = localStorage.getItem('hasLaunchedBefore');
const showWelcome = localStorage.getItem('showWelcomeOnStartup') !== 'false';
@@ -3922,12 +4402,13 @@ function updateMergeFilesList() {
});
}
// Task 27: pdf-lib 1.17.1 cannot encrypt, so encrypt/decrypt/permissions fail
// honestly in the main process. Ask main once whether password protection is
// available and, when it is not, disable the three sections' controls with an
// explanatory hint instead of letting the user fill the form only to see the
// operation fail. If the capability query itself fails, leave the controls
// enabled — main still fails the operation honestly on submit.
// Password ops (encrypt/decrypt/permissions) are backed by @cantoo/pdf-lib,
// which supports real encryption. Main reports its capability probe once; if a
// build ever ships a library without encryption support, disable the three
// sections' controls with an explanatory hint instead of letting the user fill
// the form only to see the operation fail. If the capability query itself
// fails, leave the controls enabled — main still fails the operation honestly
// on submit.
async function applyPDFPasswordProtectionAvailability() {
let capabilities;
try {
@@ -3947,14 +4428,14 @@ async function applyPDFPasswordProtectionAvailability() {
const hint = document.createElement('p');
hint.className = 'warning-message pdf-unavailable-hint';
hint.textContent =
'Password protection is not available in this build (pdf-lib lacks encryption support).';
'Password protection is not available in this build (the PDF library lacks encryption support).';
section.prepend(hint);
}
}
// PDF Editor Event Listeners
document.addEventListener('DOMContentLoaded', () => {
// Disable password-protection controls when pdf-lib lacks encryption support
// Disable password-protection controls if the PDF library lacks encryption support
applyPDFPasswordProtectionAvailability();
// Close PDF Editor Dialog
@@ -4742,33 +5223,39 @@ ipcRenderer.on('pdf-operation-progress', (event, { message, progress }) => {
}
});
// Add math rendering support using KaTeX for enhanced preview
// Add math rendering support using KaTeX for enhanced preview.
// KaTeX is bundled locally (assets/katex CSS + the katex npm package) so math
// renders offline and nothing is fetched from a CDN at runtime (the old
// jsdelivr links broke math rendering whenever the machine was offline and
// leaked document content requests to a third party).
function initMathSupport() {
// Add KaTeX CSS
const katexCSS = document.createElement('link');
katexCSS.rel = 'stylesheet';
katexCSS.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css';
katexCSS.crossOrigin = 'anonymous';
document.head.appendChild(katexCSS);
// KaTeX CSS ships in index.html via <link href="assets/katex/katex.min.css">,
// including its font files. Only the JS needs wiring up here.
try {
// The renderer runs with nodeIntegration, so the locally installed npm
// package can be required directly — no script-tag loading needed.
// katex exports the katex object; the auto-render contrib exports the
// renderMathInElement function itself (webpack default interop), so
// resolve both shapes defensively.
const katex = require('katex');
const autoRender = require('katex/contrib/auto-render');
window.katex = katex;
const renderFn =
typeof autoRender === 'function'
? autoRender
: autoRender.renderMathInElement || autoRender.default;
if (typeof renderFn !== 'function') throw new Error('auto-render export not found');
window.renderMathInElement = renderFn;
} catch (error) {
console.warn('Local KaTeX unavailable, math rendering disabled:', error.message);
return;
}
// Add KaTeX JS
const katexJS = document.createElement('script');
katexJS.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js';
katexJS.crossOrigin = 'anonymous';
katexJS.onload = () => {
// Add auto-render extension
const autoRenderJS = document.createElement('script');
autoRenderJS.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js';
autoRenderJS.crossOrigin = 'anonymous';
autoRenderJS.onload = () => {
// Re-render current preview to include math
if (tabManager) {
tabManager.updatePreview();
}
};
document.head.appendChild(autoRenderJS);
};
document.head.appendChild(katexJS);
// Math is now available synchronously; re-render the current preview so an
// already-rendered tab picks up math without waiting for the next edit.
if (tabManager) {
tabManager.updatePreview();
}
}
// Initialize math support on load