Compare commits

...
Author SHA1 Message Date
amitwh d705cfc30b fix(batch): resolve pandoc path handling and include-subfolders option
- Normalize pandoc command parsing with path.basename() to support bundled binary paths
- Use bundled pandoc binary in convertWithPandoc instead of relying on PATH
- Forward includeSubfolders checkbox state from renderer to main process
- Add pandoc availability check before batch conversion
- Re-enable Start button when batch conversion completes
- Clean up obsolete dist build artifact causing test snapshot warning
- Bump version to 4.4.4
2026-06-30 12:56:33 +05:30
86 changed files with 16876 additions and 13476 deletions
+1 -1
View File
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version
v4.1.0
v4.4.4
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "markdown-converter",
"version": "4.4.1",
"version": "4.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "markdown-converter",
"version": "4.4.1",
"version": "4.4.4",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "markdown-converter",
"version": "4.4.3",
"version": "4.4.4",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js",
"scripts": {
+89 -89
View File
@@ -12,100 +12,100 @@
* @type {import('../types').FileSystemAdapter}
*/
const electronFsAdapter = {
/**
* Read file content
* @param {string} path - File path
* @returns {Promise<string>} File content
*/
async readFile(path) {
return await window.electronAPI.file.read(path);
},
/**
* Read file content
* @param {string} path - File path
* @returns {Promise<string>} File content
*/
async readFile(path) {
return await window.electronAPI.file.read(path);
},
/**
* Write content to file
* @param {string} path - File path
* @param {string} content - File content
* @returns {Promise<void>}
*/
async writeFile(path, content) {
return await window.electronAPI.file.write(path, content);
},
/**
* Write content to file
* @param {string} path - File path
* @param {string} content - File content
* @returns {Promise<void>}
*/
async writeFile(path, content) {
return await window.electronAPI.file.write(path, content);
},
/**
* Delete file
* @param {string} path - File path
* @returns {Promise<void>}
*/
async deleteFile(path) {
return await window.electronAPI.file.delete(path);
},
/**
* Delete file
* @param {string} path - File path
* @returns {Promise<void>}
*/
async deleteFile(path) {
return await window.electronAPI.file.delete(path);
},
/**
* Ensure directory exists
* @param {string} path - Directory path
* @returns {Promise<void>}
*/
async ensureDir(path) {
return await window.electronAPI.file.ensureDir(path);
},
/**
* Ensure directory exists
* @param {string} path - Directory path
* @returns {Promise<void>}
*/
async ensureDir(path) {
return await window.electronAPI.file.ensureDir(path);
},
/**
* List directory contents
* @param {string} path - Directory path
* @returns {Promise<Array<import('../types').FileInfo>>}
*/
async listDirectory(path) {
const result = await window.electronAPI.invoke('list-directory', path);
if (!result?.entries) {
return [];
}
return result.entries.map((entry) => ({
name: entry.name,
isDir: entry.isDirectory,
size: entry.size ?? 0,
modified: entry.modified ?? 0,
path: entry.path
}));
},
/**
* Check if path exists
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async exists(path) {
return await window.electronAPI.file.exists(path);
},
/**
* Check if path is a directory
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async isDirectory(path) {
return await window.electronAPI.file.isDirectory(path);
},
/**
* Copy file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async copy(source, dest) {
return await window.electronAPI.file.copy(source, dest);
},
/**
* Move file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async move(source, dest) {
return await window.electronAPI.file.move(source, dest);
/**
* List directory contents
* @param {string} path - Directory path
* @returns {Promise<Array<import('../types').FileInfo>>}
*/
async listDirectory(path) {
const result = await window.electronAPI.invoke('list-directory', path);
if (!result?.entries) {
return [];
}
return result.entries.map((entry) => ({
name: entry.name,
isDir: entry.isDirectory,
size: entry.size ?? 0,
modified: entry.modified ?? 0,
path: entry.path,
}));
},
/**
* Check if path exists
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async exists(path) {
return await window.electronAPI.file.exists(path);
},
/**
* Check if path is a directory
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async isDirectory(path) {
return await window.electronAPI.file.isDirectory(path);
},
/**
* Copy file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async copy(source, dest) {
return await window.electronAPI.file.copy(source, dest);
},
/**
* Move file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async move(source, dest) {
return await window.electronAPI.file.move(source, dest);
},
};
module.exports = { electronFsAdapter };
+1 -1
View File
@@ -129,5 +129,5 @@
*/
module.exports = {
// Type definitions are JSDoc only, no runtime exports needed
// Type definitions are JSDoc only, no runtime exports needed
};
+41 -31
View File
@@ -5,19 +5,19 @@
const { analyze } = require('./writing-analytics');
function showAnalyticsModal(tabManager) {
const existing = document.getElementById('analytics-modal');
if (existing) existing.remove();
const existing = document.getElementById('analytics-modal');
if (existing) existing.remove();
const content = tabManager.getEditorContent();
const metrics = analyze(content);
const content = tabManager.getEditorContent();
const metrics = analyze(content);
const overlay = document.createElement('div');
overlay.id = 'analytics-modal';
overlay.className = 'analytics-overlay';
const overlay = document.createElement('div');
overlay.id = 'analytics-modal';
overlay.className = 'analytics-overlay';
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
overlay.innerHTML = `
overlay.innerHTML = `
<div class="analytics-modal">
<div class="analytics-header">
<h2>Writing Analytics</h2>
@@ -61,11 +61,15 @@ function showAnalyticsModal(tabManager) {
<span class="analytics-label">Avg Sentence</span>
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
</div>
${metrics.longestSentenceLength > 0 ? `
${
metrics.longestSentenceLength > 0
? `
<div class="analytics-row analytics-longest">
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
</div>` : ''}
</div>`
: ''
}
</div>
<div class="analytics-section">
@@ -74,39 +78,45 @@ function showAnalyticsModal(tabManager) {
<span class="analytics-label">Unique</span>
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
</div>
${metrics.topWords.length > 0 ? `
${
metrics.topWords.length > 0
? `
<div class="word-cloud">
${metrics.topWords.map(w => {
${metrics.topWords
.map((w) => {
const scale = 13 + Math.round((w.count / maxCount) * 3);
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
}).join('')}
</div>` : ''}
})
.join('')}
</div>`
: ''
}
</div>
</div>
</div>
`;
const closeBtn = overlay.querySelector('.analytics-close');
closeBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
const closeBtn = overlay.querySelector('.analytics-close');
closeBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
const escHandler = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
const escHandler = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
document.body.appendChild(overlay);
document.body.appendChild(overlay);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
module.exports = { showAnalyticsModal };
+177 -104
View File
@@ -4,124 +4,197 @@
*/
const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
'could', 'should', 'to', 'of', 'in', 'for', 'on', 'with',
'at', 'by', 'from', 'as', 'and', 'or', 'but', 'if', 'it',
'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her',
'they', 'them', 'their', 'not', 'no', 'so', 'than', 'too',
'very', 'also', 'just', 'about', 'up', 'out', 'what', 'which', 'who'
'the',
'a',
'an',
'is',
'are',
'was',
'were',
'be',
'been',
'have',
'has',
'had',
'do',
'does',
'did',
'will',
'would',
'could',
'should',
'to',
'of',
'in',
'for',
'on',
'with',
'at',
'by',
'from',
'as',
'and',
'or',
'but',
'if',
'it',
'its',
'this',
'that',
'these',
'those',
'i',
'me',
'my',
'we',
'our',
'you',
'your',
'he',
'him',
'his',
'she',
'her',
'they',
'them',
'their',
'not',
'no',
'so',
'than',
'too',
'very',
'also',
'just',
'about',
'up',
'out',
'what',
'which',
'who',
]);
function countSyllables(word) {
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
word = word.replace(/^y/, '');
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
word = word.replace(/^y/, '');
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
}
function extractWords(text) {
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
}
function getReadabilityLabel(score) {
if (score >= 90) return 'Very Easy';
if (score >= 70) return 'Easy';
if (score >= 50) return 'Standard';
if (score >= 30) return 'Difficult';
return 'Very Difficult';
if (score >= 90) return 'Very Easy';
if (score >= 70) return 'Easy';
if (score >= 50) return 'Standard';
if (score >= 30) return 'Difficult';
return 'Very Difficult';
}
function analyze(text) {
if (!text || !text.trim()) {
return {
wordCount: 0,
sentenceCount: 0,
paragraphCount: 0,
fleschEase: 0,
fleschGrade: 0,
readabilityLabel: 'N/A',
readingTime: 0,
speakingTime: 0,
uniqueWordCount: 0,
lexicalDiversity: 0,
avgSentenceLength: 0,
longestSentence: '',
longestSentenceLength: 0,
topWords: []
};
}
const words = extractWords(text);
const wordCount = words.length;
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(Boolean);
const sentenceCount = Math.max(sentences.length, 1);
const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
const paragraphCount = Math.max(paragraphs.length, 1);
let totalSyllables = 0;
for (const w of words) {
totalSyllables += countSyllables(w);
}
const fleschEase = Math.round((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10) / 10;
const fleschGrade = Math.round((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10) / 10;
const readabilityLabel = getReadabilityLabel(fleschEase);
const readingTime = Math.ceil(wordCount / 200);
const speakingTime = Math.ceil(wordCount / 130);
const uniqueWords = new Set(words.map(w => w.toLowerCase()));
const uniqueWordCount = uniqueWords.size;
const lexicalDiversity = wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
let longestSentence = '';
let longestSentenceLength = 0;
for (const s of sentences) {
const sWords = extractWords(s);
if (sWords.length > longestSentenceLength) {
longestSentenceLength = sWords.length;
longestSentence = s.trim();
}
}
if (longestSentence.length > 80) {
longestSentence = longestSentence.substring(0, 80) + '...';
}
const wordFreq = {};
for (const w of words) {
const lower = w.toLowerCase();
if (!STOP_WORDS.has(lower) && lower.length > 1) {
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
}
}
const topWords = Object.entries(wordFreq)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([word, count]) => ({ word, count }));
if (!text || !text.trim()) {
return {
wordCount,
sentenceCount,
paragraphCount,
fleschEase,
fleschGrade,
readabilityLabel,
readingTime,
speakingTime,
uniqueWordCount,
lexicalDiversity,
avgSentenceLength,
longestSentence,
longestSentenceLength,
topWords
wordCount: 0,
sentenceCount: 0,
paragraphCount: 0,
fleschEase: 0,
fleschGrade: 0,
readabilityLabel: 'N/A',
readingTime: 0,
speakingTime: 0,
uniqueWordCount: 0,
lexicalDiversity: 0,
avgSentenceLength: 0,
longestSentence: '',
longestSentenceLength: 0,
topWords: [],
};
}
const words = extractWords(text);
const wordCount = words.length;
const sentences = text
.split(/[.!?]+/)
.map((s) => s.trim())
.filter(Boolean);
const sentenceCount = Math.max(sentences.length, 1);
const paragraphs = text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const paragraphCount = Math.max(paragraphs.length, 1);
let totalSyllables = 0;
for (const w of words) {
totalSyllables += countSyllables(w);
}
const fleschEase =
Math.round(
(206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10
) / 10;
const fleschGrade =
Math.round(
(0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10
) / 10;
const readabilityLabel = getReadabilityLabel(fleschEase);
const readingTime = Math.ceil(wordCount / 200);
const speakingTime = Math.ceil(wordCount / 130);
const uniqueWords = new Set(words.map((w) => w.toLowerCase()));
const uniqueWordCount = uniqueWords.size;
const lexicalDiversity =
wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
let longestSentence = '';
let longestSentenceLength = 0;
for (const s of sentences) {
const sWords = extractWords(s);
if (sWords.length > longestSentenceLength) {
longestSentenceLength = sWords.length;
longestSentence = s.trim();
}
}
if (longestSentence.length > 80) {
longestSentence = longestSentence.substring(0, 80) + '...';
}
const wordFreq = {};
for (const w of words) {
const lower = w.toLowerCase();
if (!STOP_WORDS.has(lower) && lower.length > 1) {
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
}
}
const topWords = Object.entries(wordFreq)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([word, count]) => ({ word, count }));
return {
wordCount,
sentenceCount,
paragraphCount,
fleschEase,
fleschGrade,
readabilityLabel,
readingTime,
speakingTime,
uniqueWordCount,
lexicalDiversity,
avgSentenceLength,
longestSentence,
longestSentenceLength,
topWords,
};
}
module.exports = { analyze };
+675 -526
View File
File diff suppressed because it is too large Load Diff
+91 -87
View File
@@ -1,109 +1,113 @@
class CommandPalette {
constructor() {
this.overlay = document.getElementById('command-palette-overlay');
this.input = document.getElementById('command-palette-input');
this.results = document.getElementById('command-palette-results');
this.commands = [];
this.selectedIndex = 0;
this.filteredCommands = [];
this.setupEventListeners();
}
constructor() {
this.overlay = document.getElementById('command-palette-overlay');
this.input = document.getElementById('command-palette-input');
this.results = document.getElementById('command-palette-results');
this.commands = [];
this.selectedIndex = 0;
this.filteredCommands = [];
this.setupEventListeners();
}
register(label, shortcut, action) {
this.commands.push({ label, shortcut, action });
}
register(label, shortcut, action) {
this.commands.push({ label, shortcut, action });
}
open() {
this.overlay.classList.remove('hidden');
this.input.value = '';
this.input.focus();
this.selectedIndex = 0;
this.renderResults('');
}
open() {
this.overlay.classList.remove('hidden');
this.input.value = '';
this.input.focus();
this.selectedIndex = 0;
this.renderResults('');
}
close() {
this.overlay.classList.add('hidden');
}
close() {
this.overlay.classList.add('hidden');
}
isOpen() {
return !this.overlay.classList.contains('hidden');
}
isOpen() {
return !this.overlay.classList.contains('hidden');
}
setupEventListeners() {
this.input.addEventListener('input', () => {
this.selectedIndex = 0;
this.renderResults(this.input.value);
});
setupEventListeners() {
this.input.addEventListener('input', () => {
this.selectedIndex = 0;
this.renderResults(this.input.value);
});
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.close();
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeSelected();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
this.updateSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
}
});
}
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.close();
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeSelected();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
this.updateSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
}
});
}
renderResults(query) {
this.filteredCommands = query
? this.commands.filter(cmd => cmd.label.toLowerCase().includes(query.toLowerCase()))
: [...this.commands];
renderResults(query) {
this.filteredCommands = query
? this.commands.filter((cmd) => cmd.label.toLowerCase().includes(query.toLowerCase()))
: [...this.commands];
this.results.innerHTML = this.filteredCommands.map((cmd, i) => `
this.results.innerHTML = this.filteredCommands
.map(
(cmd, i) => `
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
</div>
`).join('');
`
)
.join('');
this.results.querySelectorAll('.command-item').forEach((el) => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index);
this.filteredCommands[idx].action();
this.close();
});
el.addEventListener('mouseenter', () => {
this.selectedIndex = parseInt(el.dataset.index);
this.updateSelection();
});
});
}
this.results.querySelectorAll('.command-item').forEach((el) => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index);
this.filteredCommands[idx].action();
this.close();
});
el.addEventListener('mouseenter', () => {
this.selectedIndex = parseInt(el.dataset.index);
this.updateSelection();
});
});
}
highlightMatch(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<strong>$1</strong>');
}
highlightMatch(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<strong>$1</strong>');
}
updateSelection() {
this.results.querySelectorAll('.command-item').forEach((el, i) => {
el.classList.toggle('selected', i === this.selectedIndex);
});
// Scroll selected into view
const selected = this.results.querySelector('.command-item.selected');
if (selected) selected.scrollIntoView({ block: 'nearest' });
}
updateSelection() {
this.results.querySelectorAll('.command-item').forEach((el, i) => {
el.classList.toggle('selected', i === this.selectedIndex);
});
// Scroll selected into view
const selected = this.results.querySelector('.command-item.selected');
if (selected) selected.scrollIntoView({ block: 'nearest' });
}
executeSelected() {
if (this.filteredCommands[this.selectedIndex]) {
this.filteredCommands[this.selectedIndex].action();
this.close();
}
executeSelected() {
if (this.filteredCommands[this.selectedIndex]) {
this.filteredCommands[this.selectedIndex].action();
this.close();
}
}
}
module.exports = { CommandPalette };
+36 -29
View File
@@ -12,38 +12,23 @@ const { EditorState } = require('@codemirror/state');
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
// Language extensions loaded lazily on first use
let _javascript, _html, _css, _json, _python;
const {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} = require('@codemirror/commands');
const {
searchKeymap,
highlightSelectionMatches,
} = require('@codemirror/search');
const {
autocompletion,
completionKeymap,
} = require('@codemirror/autocomplete');
const {
bracketMatching,
foldGutter,
indentOnInput,
} = require('@codemirror/language');
const { defaultKeymap, history, historyKeymap, indentWithTab } = require('@codemirror/commands');
const { searchKeymap, highlightSelectionMatches } = require('@codemirror/search');
const { autocompletion, completionKeymap } = require('@codemirror/autocomplete');
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
const { oneDark } = require('@codemirror/theme-one-dark');
// Custom theme for JetBrains Mono font
const jetBrainsMonoTheme = EditorView.theme({
'&': {
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace"
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace",
},
'.cm-content': {
fontFamily: 'inherit'
fontFamily: 'inherit',
},
'.cm-scroller': {
fontFamily: 'inherit'
}
fontFamily: 'inherit',
},
});
/**
@@ -59,7 +44,14 @@ const jetBrainsMonoTheme = EditorView.theme({
* @returns {EditorView} the created editor view
*/
function createEditor(parentElement, options = {}) {
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight);
console.log(
'[createEditor] Called with parentElement:',
parentElement?.id,
'dimensions:',
parentElement?.clientWidth,
'x',
parentElement?.clientHeight
);
if (!parentElement) {
console.error('[createEditor] ERROR: parentElement is null or undefined!');
return null;
@@ -125,11 +117,26 @@ function createEditor(parentElement, options = {}) {
*/
function getLanguageExtension(lang) {
const loaders = {
javascript: () => { if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript; return _javascript(); },
html: () => { if (!_html) _html = require('@codemirror/lang-html').html; return _html(); },
css: () => { if (!_css) _css = require('@codemirror/lang-css').css; return _css(); },
json: () => { if (!_json) _json = require('@codemirror/lang-json').json; return _json(); },
python: () => { if (!_python) _python = require('@codemirror/lang-python').python; return _python(); },
javascript: () => {
if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
return _javascript();
},
html: () => {
if (!_html) _html = require('@codemirror/lang-html').html;
return _html();
},
css: () => {
if (!_css) _css = require('@codemirror/lang-css').css;
return _css();
},
json: () => {
if (!_json) _json = require('@codemirror/lang-json').json;
return _json();
},
python: () => {
if (!_python) _python = require('@codemirror/lang-python').python;
return _python();
},
markdown: () => markdown({ base: markdownLanguage }),
};
loaders.js = loaders.javascript;
+45 -45
View File
@@ -2,74 +2,74 @@
/* Inter Font Family */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('../assets/fonts/Inter-Light.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('../assets/fonts/Inter-Light.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
}
/* JetBrains Mono Font Family - For code, markdown editor, and ASCII art */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Regular.woff2') format('woff2');
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Medium.woff2') format('woff2');
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Bold.woff2') format('woff2');
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
+2381 -1594
View File
File diff suppressed because it is too large Load Diff
+1623 -1172
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ async function getStatus(dir) {
try {
const git = getGitInstance(dir);
return await git.status();
} catch (err) {
} catch {
return { error: 'Not a git repository' };
}
}
+62 -43
View File
@@ -4,11 +4,11 @@ const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
function parsePageRanges(rangeString, totalPages) {
const pages = [];
const ranges = rangeString.split(',').map(r => r.trim());
const ranges = rangeString.split(',').map((r) => r.trim());
for (const range of ranges) {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
for (let i = start; i <= end && i <= totalPages; i++) {
if (i > 0 && !pages.includes(i - 1)) {
pages.push(i - 1);
@@ -27,11 +27,13 @@ function parsePageRanges(rangeString, totalPages) {
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255
} : { r: 0, g: 0, b: 0 };
return result
? {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255,
}
: { r: 0, g: 0, b: 0 };
}
async function pdfMerge(data) {
@@ -42,7 +44,7 @@ async function pdfMerge(data) {
const pdfBytes = fs.readFileSync(filePath);
const pdf = await PDFDocument.load(pdfBytes);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach(page => mergedPdf.addPage(page));
copiedPages.forEach((page) => mergedPdf.addPage(page));
}
const pdfBytes = await mergedPdf.save();
@@ -63,13 +65,13 @@ async function pdfSplit(data) {
const splits = [];
if (data.splitMode === 'pages') {
const ranges = data.pageRanges.split(',').map(r => r.trim());
const ranges = data.pageRanges.split(',').map((r) => r.trim());
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
const pages = [];
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
for (let p = start; p <= end && p <= totalPages; p++) {
pages.push(p - 1);
}
@@ -108,7 +110,7 @@ async function pdfSplit(data) {
for (const split of splits) {
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(pdf, split.pages);
copiedPages.forEach(page => newPdf.addPage(page));
copiedPages.forEach((page) => newPdf.addPage(page));
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
const newPdfBytes = await newPdf.save();
@@ -129,18 +131,18 @@ async function pdfCompress(data) {
const compressedPdfBytes = await pdf.save({
useObjectStreams: true,
addDefaultPage: false,
objectsPerTick: 50
objectsPerTick: 50,
});
fs.writeFileSync(data.outputPath, compressedPdfBytes);
const originalSize = fs.statSync(data.inputPath).size;
const compressedSize = fs.statSync(data.outputPath).size;
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
const savings = (((originalSize - compressedSize) / originalSize) * 100).toFixed(1);
return {
success: true,
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`,
};
} catch (error) {
return { success: false, error: error.message };
@@ -160,7 +162,7 @@ async function pdfRotate(data) {
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
}
pagesToRotate.forEach(pageIndex => {
pagesToRotate.forEach((pageIndex) => {
const page = pdf.getPage(pageIndex);
page.setRotation(degrees(data.angle));
});
@@ -170,7 +172,7 @@ async function pdfRotate(data) {
return {
success: true,
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`,
};
} catch (error) {
return { success: false, error: error.message };
@@ -185,16 +187,18 @@ async function pdfDeletePages(data) {
const pagesToDelete = parsePageRanges(data.pages, totalPages);
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
pdf.removePage(pageIndex);
});
pagesToDelete
.sort((a, b) => b - a)
.forEach((pageIndex) => {
pdf.removePage(pageIndex);
});
const newPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, newPdfBytes);
return {
success: true,
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`,
};
} catch (error) {
return { success: false, error: error.message };
@@ -207,7 +211,7 @@ async function pdfReorder(data) {
const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount();
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1);
const newOrder = data.newOrder.split(',').map((n) => parseInt(n.trim()) - 1);
if (newOrder.length !== totalPages) {
return { success: false, error: `New order must include all ${totalPages} pages` };
@@ -215,7 +219,7 @@ async function pdfReorder(data) {
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(pdf, newOrder);
copiedPages.forEach(page => newPdf.addPage(page));
copiedPages.forEach((page) => newPdf.addPage(page));
const reorderedPdfBytes = await newPdf.save();
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
@@ -246,7 +250,9 @@ async function pdfWatermark(data) {
const page = pdf.getPage(pageIndex);
const { width, height } = page.getSize();
let x, y, rotation = 0;
let x,
y,
rotation = 0;
switch (data.position) {
case 'center':
@@ -294,7 +300,7 @@ async function pdfWatermark(data) {
font,
color: rgb(color.r, color.g, color.b),
opacity: data.opacity,
rotate: degrees(rotation)
rotate: degrees(rotation),
});
}
@@ -303,7 +309,7 @@ async function pdfWatermark(data) {
return {
success: true,
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`,
};
} catch (error) {
return { success: false, error: error.message };
@@ -325,8 +331,8 @@ async function pdfEncrypt(data) {
annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly
}
documentAssembly: data.permissions.documentAssembly,
},
});
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
@@ -336,7 +342,8 @@ async function pdfEncrypt(data) {
if (error.message.includes('encrypt') || error.message.includes('password')) {
return {
success: false,
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
error:
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
};
}
return { success: false, error: error.message };
@@ -375,8 +382,8 @@ async function pdfSetPermissions(data) {
annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly
}
documentAssembly: data.permissions.documentAssembly,
},
});
fs.writeFileSync(data.outputPath, newPdfBytes);
@@ -386,7 +393,8 @@ async function pdfSetPermissions(data) {
if (error.message.includes('encrypt') || error.message.includes('permission')) {
return {
success: false,
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
error:
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
};
}
return { success: false, error: error.message };
@@ -395,17 +403,28 @@ async function pdfSetPermissions(data) {
function executeOperation(operation, data) {
switch (operation) {
case 'merge': return pdfMerge(data);
case 'split': return pdfSplit(data);
case 'compress': return pdfCompress(data);
case 'rotate': return pdfRotate(data);
case 'delete': return pdfDeletePages(data);
case 'reorder': return pdfReorder(data);
case 'watermark': return pdfWatermark(data);
case 'encrypt': return pdfEncrypt(data);
case 'decrypt': return pdfDecrypt(data);
case 'permissions': return pdfSetPermissions(data);
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
case 'merge':
return pdfMerge(data);
case 'split':
return pdfSplit(data);
case 'compress':
return pdfCompress(data);
case 'rotate':
return pdfRotate(data);
case 'delete':
return pdfDeletePages(data);
case 'reorder':
return pdfReorder(data);
case 'watermark':
return pdfWatermark(data);
case 'encrypt':
return pdfEncrypt(data);
case 'decrypt':
return pdfDecrypt(data);
case 'permissions':
return pdfSetPermissions(data);
default:
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
}
}
@@ -429,5 +448,5 @@ module.exports = {
pdfDecrypt,
pdfSetPermissions,
executeOperation,
getPageCount
getPageCount,
};
+1 -3
View File
@@ -5,9 +5,7 @@
"description": "Demonstrates the plugin system. Safe to delete.",
"icon": "puzzle",
"extensionPoints": {
"commands": [
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
]
"commands": [{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }]
},
"settings": []
}
+69 -39
View File
@@ -9,7 +9,7 @@ class WritingStudioPlugin extends PluginAPI {
this.context = context;
this.sprintEngine = new SprintEngine({
onEvent: (name, data) => context.events.emit(name, data)
onEvent: (name, data) => context.events.emit(name, data),
});
this.goalTracker = new GoalTracker(context.settings);
this.snapshotManager = new SnapshotManager(context.settings);
@@ -17,14 +17,14 @@ class WritingStudioPlugin extends PluginAPI {
readFile: (p) => context.ipc.invoke('read-file', p),
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
fileExists: (p) => context.ipc.invoke('path-exists', p),
listDir: (p) => context.ipc.invoke('list-directory', p)
listDir: (p) => context.ipc.invoke('list-directory', p),
});
this._engines = {
sprint: this.sprintEngine,
goals: this.goalTracker,
snapshots: this.snapshotManager,
projects: this.projectManager
projects: this.projectManager,
};
this._registerCommands(context);
@@ -34,59 +34,89 @@ class WritingStudioPlugin extends PluginAPI {
_registerCommands(context) {
const { sprintEngine, snapshotManager, goalTracker } = this;
context.commands.register('start-sprint', 'Studio: Start Sprint', () => {
const duration = context.settings.get('sprintDuration') || 25;
const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length;
sprintEngine.start(duration, words);
}, 'Ctrl+Alt+S');
context.commands.register(
'start-sprint',
'Studio: Start Sprint',
() => {
const duration = context.settings.get('sprintDuration') || 25;
const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length;
sprintEngine.start(duration, words);
},
'Ctrl+Alt+S'
);
context.commands.register('stop-sprint', 'Studio: Stop Sprint', () => {
if (!sprintEngine.isActive()) return;
const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length;
const result = sprintEngine.stop(words);
goalTracker.addWords(result.wordDelta);
context.events.emit('sprint:stopped', result);
}, 'Ctrl+Alt+Shift+S');
context.commands.register(
'stop-sprint',
'Studio: Stop Sprint',
() => {
if (!sprintEngine.isActive()) return;
const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length;
const result = sprintEngine.stop(words);
goalTracker.addWords(result.wordDelta);
context.events.emit('sprint:stopped', result);
},
'Ctrl+Alt+Shift+S'
);
context.commands.register('take-snapshot', 'Studio: Take Snapshot', () => {
const content = context.editor.getContent() || '';
snapshotManager.create(content, 'manual');
context.events.emit('snapshot:created', {});
}, 'Ctrl+Alt+N');
context.commands.register(
'take-snapshot',
'Studio: Take Snapshot',
() => {
const content = context.editor.getContent() || '';
snapshotManager.create(content, 'manual');
context.events.emit('snapshot:created', {});
},
'Ctrl+Alt+N'
);
context.commands.register('restore-last-snapshot', 'Studio: Restore Last Snapshot', () => {
const snaps = snapshotManager.list();
if (snaps.length === 0) return;
const content = snapshotManager.restore(snaps[0].id);
context.editor.insertAtCursor(content);
}, 'Ctrl+Alt+Z');
context.commands.register(
'restore-last-snapshot',
'Studio: Restore Last Snapshot',
() => {
const snaps = snapshotManager.list();
if (snaps.length === 0) return;
const content = snapshotManager.restore(snaps[0].id);
context.editor.insertAtCursor(content);
},
'Ctrl+Alt+Z'
);
context.commands.register('new-project', 'Studio: New Project', () => {
context.events.emit('studio:new-project', {});
});
context.commands.register('compile-manuscript', 'Studio: Compile Manuscript', () => {
context.events.emit('studio:compile', {});
}, 'Ctrl+Alt+E');
context.commands.register(
'compile-manuscript',
'Studio: Compile Manuscript',
() => {
context.events.emit('studio:compile', {});
},
'Ctrl+Alt+E'
);
context.commands.register('proofread-document', 'Studio: Proofread Document', () => {
if (context.events.hasHandler('ai:analyze')) {
const content = context.editor.getContent() || '';
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
}
}, 'Ctrl+Alt+G');
context.commands.register(
'proofread-document',
'Studio: Proofread Document',
() => {
if (context.events.hasHandler('ai:analyze')) {
const content = context.editor.getContent() || '';
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
}
},
'Ctrl+Alt+G'
);
}
_registerStatusBar(context) {
context.statusBar.registerIndicator('word-goal', {
text: '0/1000',
tooltip: 'Daily word goal progress'
tooltip: 'Daily word goal progress',
});
context.statusBar.registerIndicator('sprint-timer', {
text: '',
tooltip: 'Writing sprint timer'
tooltip: 'Writing sprint timer',
});
}
@@ -15,17 +15,34 @@
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
{ "id": "restore-last-snapshot", "label": "Studio: Restore Last Snapshot", "shortcut": "Ctrl+Alt+Z" },
{
"id": "restore-last-snapshot",
"label": "Studio: Restore Last Snapshot",
"shortcut": "Ctrl+Alt+Z"
},
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
{ "id": "compile-manuscript", "label": "Studio: Compile Manuscript", "shortcut": "Ctrl+Alt+E" },
{ "id": "proofread-document", "label": "Studio: Proofread Document", "shortcut": "Ctrl+Alt+G" }
{
"id": "compile-manuscript",
"label": "Studio: Compile Manuscript",
"shortcut": "Ctrl+Alt+E"
},
{
"id": "proofread-document",
"label": "Studio: Proofread Document",
"shortcut": "Ctrl+Alt+G"
}
],
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
},
"settings": [
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
{ "key": "autoSnapshotInterval", "type": "number", "default": 0, "label": "Auto-snapshot interval (min, 0=off)" },
{
"key": "autoSnapshotInterval",
"type": "number",
"default": 0,
"label": "Auto-snapshot interval (min, 0=off)"
},
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
]
}
@@ -29,7 +29,8 @@ function renderGoalsPanel(container, { engines, settings }) {
const row = document.createElement('div');
row.className = 'ws-stat-row';
const label = document.createElement('span');
label.textContent = progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
label.textContent =
progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
const pct = document.createElement('span');
pct.className = 'ws-pct';
pct.textContent = progress.pct + '%';
@@ -81,7 +82,7 @@ function renderGoalsPanel(container, { engines, settings }) {
const chart = document.createElement('div');
chart.className = 'ws-chart';
const maxWords = Math.max(...last30.map(d => d.words), 1);
const maxWords = Math.max(...last30.map((d) => d.words), 1);
for (const day of last30) {
const barEl = document.createElement('div');
const height = Math.max(2, (day.words / maxWords) * 60);
@@ -69,7 +69,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
const row = document.createElement('div');
row.className = 'ws-stat-row';
const label = document.createElement('span');
label.textContent = stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
label.textContent =
stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
const pct = document.createElement('span');
pct.className = 'ws-pct';
pct.textContent = stats.pctComplete + '%';
@@ -42,7 +42,7 @@ function renderProofreadPanel(container, { events, editor }) {
if (result && result.issues) {
renderIssues(issuesList, result.issues);
}
}
},
});
});
}
@@ -80,7 +80,10 @@ function renderIssues(container, issues) {
const actions = document.createElement('div');
actions.className = 'ws-issue-actions';
for (const [action, label] of [['accept', 'Accept'], ['dismiss', 'Dismiss']]) {
for (const [, label] of [
['accept', 'Accept'],
['dismiss', 'Dismiss'],
]) {
const actionBtn = document.createElement('button');
actionBtn.className = 'ws-btn ws-btn-sm';
actionBtn.textContent = label;
@@ -45,7 +45,11 @@ function renderSnapshotsPanel(container, { engines, editor }) {
const actions = document.createElement('div');
actions.className = 'ws-snapshot-actions';
for (const [action, text, cls] of [['restore', 'Restore', ''], ['diff', 'Diff', ''], ['delete', 'Delete', 'ws-btn-danger']]) {
for (const [action, text, cls] of [
['restore', 'Restore', ''],
['diff', 'Diff', ''],
['delete', 'Delete', 'ws-btn-danger'],
]) {
const actionBtn = document.createElement('button');
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
actionBtn.textContent = text;
@@ -12,7 +12,7 @@ class ProjectManager {
type: opts.type || 'manuscript',
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
chapters: [],
metadata: opts.metadata || {}
metadata: opts.metadata || {},
};
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
return project;
@@ -66,7 +66,7 @@ class ProjectManager {
totalWords,
chapterCount: project.chapters.length,
targetWords: target,
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0,
};
}
}
@@ -24,7 +24,7 @@ class SnapshotManager {
timestamp: new Date().toISOString(),
content,
wordCount: content.split(/\s+/).filter(Boolean).length,
label
label,
};
snaps.unshift(snap);
this._saveAll(snaps);
@@ -36,7 +36,7 @@ class SnapshotManager {
}
getById(id) {
return this._getAll().find(s => s.id === id) || null;
return this._getAll().find((s) => s.id === id) || null;
}
restore(id) {
@@ -46,7 +46,7 @@ class SnapshotManager {
}
delete(id) {
const snaps = this._getAll().filter(s => s.id !== id);
const snaps = this._getAll().filter((s) => s.id !== id);
this._saveAll(snaps);
}
@@ -59,8 +59,12 @@ class SnapshotManager {
const newSet = new Set(newLines);
let added = 0;
let removed = 0;
for (const line of newLines) { if (!oldSet.has(line)) added++; }
for (const line of oldLines) { if (!newSet.has(line)) removed++; }
for (const line of newLines) {
if (!oldSet.has(line)) added++;
}
for (const line of oldLines) {
if (!newSet.has(line)) removed++;
}
return { added, removed };
}
@@ -40,7 +40,9 @@ class SprintEngine {
}
}
isActive() { return this._active; }
isActive() {
return this._active;
}
getRemaining() {
if (!this._active) return 0;
+15 -10
View File
@@ -12,10 +12,11 @@ class PluginContext {
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
*/
constructor(deps) {
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps;
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } =
deps;
this.sidebar = {
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts)
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
};
this.commands = {
@@ -28,41 +29,45 @@ class PluginContext {
}
};
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
}
},
};
this.statusBar = {
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts)
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts),
};
this.settings = {
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb)
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb),
};
this.editor = {
getContent: () => editor.getContent(),
getSelection: () => editor.getSelection(),
insertAtCursor: (text) => editor.insertAtCursor(text),
onContentChanged: (cb) => editor.onContentChanged(cb)
onContentChanged: (cb) => editor.onContentChanged(cb),
};
this.events = {
on: (event, handler) => eventBus.on(event, handler),
off: (event, handler) => eventBus.off(event, handler),
emit: (event, payload) => eventBus.emit(event, payload),
hasHandler: (event) => eventBus.hasHandler(event)
hasHandler: (event) => eventBus.hasHandler(event),
};
this.ipc = {
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
on: (channel, handler) => ipc.on(channel, handler)
on: (channel, handler) => ipc.on(channel, handler),
};
this.exports = {
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); },
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); }
registerPreHook: (handler) => {
if (exportHooks) exportHooks.preHooks.push(handler);
},
registerPostHook: (handler) => {
if (exportHooks) exportHooks.postHooks.push(handler);
},
};
}
}
+5 -2
View File
@@ -35,7 +35,10 @@ class PluginLoader {
const loaded = require(indexPath);
PluginClass = loaded.Plugin || loaded.default || null;
} catch (err) {
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message);
console.error(
`[PluginLoader] Failed to load index.js for "${manifest.id}":`,
err.message
);
continue;
}
}
@@ -46,7 +49,7 @@ class PluginLoader {
description: manifest.description,
manifest,
PluginClass,
dir: pluginDir
dir: pluginDir,
});
this.loadedIds.add(manifest.id);
} catch (err) {
+1 -1
View File
@@ -32,7 +32,7 @@ class PluginRegistry {
settings: this.deps.settings,
editor: this.deps.editor,
ipc: this.deps.ipc,
exportHooks: this.exportHooks
exportHooks: this.exportHooks,
});
try {
+1 -1
View File
@@ -14,7 +14,7 @@ class SettingsStore {
this.backend.set(key, value);
}
onChanged(key, callback) {
onChanged(_key, _callback) {
// Deferred: plugins read settings on init/activate for MVP.
// Full change notification requires IPC watcher in main process.
}
+25 -18
View File
@@ -140,7 +140,7 @@ const ALLOWED_SEND_CHANNELS = [
// Plugin settings
'plugin-settings:get',
'plugin-settings:set'
'plugin-settings:set',
];
const ALLOWED_RECEIVE_CHANNELS = [
@@ -237,7 +237,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
'load-template-menu',
'toggle-command-palette',
'toggle-sidebar-panel',
'toggle-bottom-panel'
'toggle-bottom-panel',
];
/**
@@ -344,23 +344,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
},
// Theme Operations
theme: {
get: () => ipcRenderer.send('get-theme')
get: () => ipcRenderer.send('get-theme'),
},
// Print Operations
print: {
doPrint: (options) => ipcRenderer.send('do-print', options)
doPrint: (options) => ipcRenderer.send('do-print', options),
},
// Export Operations
export: {
withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }),
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format })
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format }),
},
// Batch Conversion
@@ -368,7 +368,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
convert: (inputFolder, outputFolder, format, options) => {
ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options });
},
selectFolder: (type) => ipcRenderer.send('select-folder', type)
selectFolder: (type) => ipcRenderer.send('select-folder', type),
},
// Universal Converter
@@ -377,8 +377,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
},
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
ipcRenderer.send('universal-convert-batch', { tool, fromFormat, toFormat, inputFolder, outputFolder });
}
ipcRenderer.send('universal-convert-batch', {
tool,
fromFormat,
toFormat,
inputFolder,
outputFolder,
});
},
},
// Header/Footer Operations
@@ -386,22 +392,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
saveLogo: (position, filePath) => ipcRenderer.send('save-header-footer-logo', { position, filePath }),
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position)
saveLogo: (position, filePath) =>
ipcRenderer.send('save-header-footer-logo', { position, filePath }),
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position),
},
// Page Settings
page: {
getSettings: () => ipcRenderer.send('get-page-settings'),
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber)
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber),
},
// PDF Operations
pdf: {
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId)
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId),
},
// Image Converter Operations
@@ -410,7 +417,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
resize: (data) => ipcRenderer.send('image-resize', data),
compress: (data) => ipcRenderer.send('image-compress', data),
rotate: (data) => ipcRenderer.send('image-rotate', data)
rotate: (data) => ipcRenderer.send('image-rotate', data),
},
// Audio Converter Operations
@@ -419,7 +426,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
extract: (data) => ipcRenderer.send('audio-extract', data),
trim: (data) => ipcRenderer.send('audio-trim', data),
merge: (data) => ipcRenderer.send('audio-merge', data)
merge: (data) => ipcRenderer.send('audio-merge', data),
},
// Video Converter Operations
@@ -429,16 +436,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
compress: (data) => ipcRenderer.send('video-compress', data),
trim: (data) => ipcRenderer.send('video-trim', data),
extractFrames: (data) => ipcRenderer.send('video-frames', data),
toGif: (data) => ipcRenderer.send('video-gif', data)
toGif: (data) => ipcRenderer.send('video-gif', data),
},
// Generator Windows
generators: {
openAscii: () => ipcRenderer.send('open-ascii-generator'),
openTable: () => ipcRenderer.send('open-table-generator')
openTable: () => ipcRenderer.send('open-table-generator'),
},
getAppVersion: () => ipcRenderer.invoke('get-app-version')
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
});
// Log successful preload initialization
+90 -90
View File
@@ -1,85 +1,85 @@
class PrintPreview {
constructor() {
this.overlay = document.getElementById('print-preview-overlay');
this.modal = window.modals?.printPreviewModal;
this._lastContent = '';
this.setupEventListeners();
constructor() {
this.overlay = document.getElementById('print-preview-overlay');
this.modal = window.modals?.printPreviewModal;
this._lastContent = '';
this.setupEventListeners();
}
open(htmlContent) {
this._lastContent = htmlContent;
if (this.modal) {
this.modal.open();
} else {
this.overlay.classList.remove('hidden');
}
this.updatePreview(htmlContent);
this.updateScaleLabel();
}
open(htmlContent) {
this._lastContent = htmlContent;
if (this.modal) {
this.modal.open();
} else {
this.overlay.classList.remove('hidden');
}
this.updatePreview(htmlContent);
this.updateScaleLabel();
close() {
if (this.modal) {
this.modal.close();
} else {
this.overlay.classList.add('hidden');
}
}
close() {
if (this.modal) {
this.modal.close();
} else {
this.overlay.classList.add('hidden');
}
}
setupEventListeners() {
document.getElementById('print-preview-close')?.addEventListener('click', () => this.close());
document.getElementById('print-cancel')?.addEventListener('click', () => this.close());
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
setupEventListeners() {
document.getElementById('print-preview-close')?.addEventListener('click', () => this.close());
document.getElementById('print-cancel')?.addEventListener('click', () => this.close());
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
// Update preview on option changes
['print-paper-size', 'print-orientation', 'print-margins'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
});
// Update preview on option changes
['print-paper-size', 'print-orientation', 'print-margins'].forEach(id => {
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
});
// Scale slider
const scaleSlider = document.getElementById('print-scale');
scaleSlider?.addEventListener('input', () => this.updateScaleLabel());
// Scale slider
const scaleSlider = document.getElementById('print-scale');
scaleSlider?.addEventListener('input', () => this.updateScaleLabel());
// Page range toggle
document.getElementById('print-pages')?.addEventListener('change', (e) => {
const rangeInput = document.getElementById('print-page-range');
if (rangeInput) {
rangeInput.classList.toggle('hidden', e.target.value !== 'custom');
}
});
// Page range toggle
document.getElementById('print-pages')?.addEventListener('change', (e) => {
const rangeInput = document.getElementById('print-page-range');
if (rangeInput) {
rangeInput.classList.toggle('hidden', e.target.value !== 'custom');
}
});
// Note: Backdrop click and Escape key are now handled by ModalManager
}
// Note: Backdrop click and Escape key are now handled by ModalManager
}
updateScaleLabel() {
const scale = document.getElementById('print-scale')?.value || 100;
const label = document.getElementById('print-scale-value');
if (label) label.textContent = `${scale}%`;
}
updateScaleLabel() {
const scale = document.getElementById('print-scale')?.value || 100;
const label = document.getElementById('print-scale-value');
if (label) label.textContent = `${scale}%`;
}
updatePreview(htmlContent) {
const frame = document.getElementById('print-preview-frame');
if (!frame) return;
updatePreview(htmlContent) {
const frame = document.getElementById('print-preview-frame');
if (!frame) return;
this._lastContent = htmlContent;
this._lastContent = htmlContent;
const orientation = document.getElementById('print-orientation')?.value || 'portrait';
const paperSize = document.getElementById('print-paper-size')?.value || 'A4';
const orientation = document.getElementById('print-orientation')?.value || 'portrait';
const paperSize = document.getElementById('print-paper-size')?.value || 'A4';
// Get dimensions for paper size
const sizes = {
A3: { width: '297mm', height: '420mm' },
A4: { width: '210mm', height: '297mm' },
A5: { width: '148mm', height: '210mm' },
Letter: { width: '8.5in', height: '11in' },
Legal: { width: '8.5in', height: '14in' },
Tabloid: { width: '11in', height: '17in' },
};
// Get dimensions for paper size
const sizes = {
'A3': { width: '297mm', height: '420mm' },
'A4': { width: '210mm', height: '297mm' },
'A5': { width: '148mm', height: '210mm' },
'Letter': { width: '8.5in', height: '11in' },
'Legal': { width: '8.5in', height: '14in' },
'Tabloid': { width: '11in', height: '17in' },
};
const size = sizes[paperSize] || sizes['A4'];
const width = orientation === 'landscape' ? size.height : size.width;
const height = orientation === 'landscape' ? size.width : size.height;
const size = sizes[paperSize] || sizes['A4'];
const width = orientation === 'landscape' ? size.height : size.width;
const height = orientation === 'landscape' ? size.width : size.height;
const previewHtml = `
const previewHtml = `
<!DOCTYPE html>
<html>
<head>
@@ -105,34 +105,34 @@ class PrintPreview {
</html>
`;
frame.srcdoc = previewHtml;
}
frame.srcdoc = previewHtml;
}
refreshPreview() {
if (this._lastContent) {
this.updatePreview(this._lastContent);
}
refreshPreview() {
if (this._lastContent) {
this.updatePreview(this._lastContent);
}
}
getOptions() {
return {
paperSize: document.getElementById('print-paper-size')?.value || 'A4',
orientation: document.getElementById('print-orientation')?.value || 'portrait',
margins: document.getElementById('print-margins')?.value || 'default',
scale: parseInt(document.getElementById('print-scale')?.value || '100'),
headers: document.getElementById('print-headers')?.checked ?? true,
background: document.getElementById('print-background')?.checked ?? true,
pages: document.getElementById('print-pages')?.value || 'all',
pageRange: document.getElementById('print-page-range')?.value || '',
};
}
getOptions() {
return {
paperSize: document.getElementById('print-paper-size')?.value || 'A4',
orientation: document.getElementById('print-orientation')?.value || 'portrait',
margins: document.getElementById('print-margins')?.value || 'default',
scale: parseInt(document.getElementById('print-scale')?.value || '100'),
headers: document.getElementById('print-headers')?.checked ?? true,
background: document.getElementById('print-background')?.checked ?? true,
pages: document.getElementById('print-pages')?.value || 'all',
pageRange: document.getElementById('print-page-range')?.value || '',
};
}
executePrint() {
const options = this.getOptions();
const { ipcRenderer } = require('electron');
ipcRenderer.send('do-print-with-options', options);
this.close();
}
executePrint() {
const options = this.getOptions();
const { ipcRenderer } = require('electron');
ipcRenderer.send('do-print-with-options', options);
this.close();
}
}
module.exports = { PrintPreview };
+5165 -4417
View File
File diff suppressed because it is too large Load Diff
+34 -34
View File
@@ -1,49 +1,49 @@
class ReplPanel {
constructor() {
this.panel = document.getElementById('bottom-panel');
this.output = document.getElementById('repl-output');
this.setupEventListeners();
}
constructor() {
this.panel = document.getElementById('bottom-panel');
this.output = document.getElementById('repl-output');
this.setupEventListeners();
}
setupEventListeners() {
document.getElementById('bottom-panel-toggle')?.addEventListener('click', () => this.toggle());
document.getElementById('repl-clear')?.addEventListener('click', () => this.clear());
}
setupEventListeners() {
document.getElementById('bottom-panel-toggle')?.addEventListener('click', () => this.toggle());
document.getElementById('repl-clear')?.addEventListener('click', () => this.clear());
}
toggle() {
this.panel.classList.toggle('collapsed');
const btn = document.getElementById('bottom-panel-toggle');
if (btn) btn.textContent = this.panel.classList.contains('collapsed') ? '\u25BC' : '\u25B2';
}
toggle() {
this.panel.classList.toggle('collapsed');
const btn = document.getElementById('bottom-panel-toggle');
if (btn) btn.textContent = this.panel.classList.contains('collapsed') ? '\u25BC' : '\u25B2';
}
show() {
this.panel.classList.remove('collapsed');
const btn = document.getElementById('bottom-panel-toggle');
if (btn) btn.textContent = '\u25B2';
}
show() {
this.panel.classList.remove('collapsed');
const btn = document.getElementById('bottom-panel-toggle');
if (btn) btn.textContent = '\u25B2';
}
clear() {
if (this.output) this.output.innerHTML = '';
}
clear() {
if (this.output) this.output.innerHTML = '';
}
appendOutput(command, result) {
const entry = document.createElement('div');
entry.className = 'repl-entry';
entry.innerHTML = `
appendOutput(command, result) {
const entry = document.createElement('div');
entry.className = 'repl-entry';
entry.innerHTML = `
<div class="repl-command">\u25B6 ${command}</div>
${result.stdout ? `<div class="repl-stdout">${this.escapeHtml(result.stdout)}</div>` : ''}
${result.stderr ? `<div class="repl-stderr">${this.escapeHtml(result.stderr)}</div>` : ''}
${result.error ? `<div class="repl-error">Error: ${this.escapeHtml(result.error)}</div>` : ''}
`;
this.output?.appendChild(entry);
this.output?.scrollTo(0, this.output.scrollHeight);
}
this.output?.appendChild(entry);
this.output?.scrollTo(0, this.output.scrollHeight);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
module.exports = { ReplPanel };
+65 -41
View File
@@ -1,7 +1,5 @@
const path = require('path');
function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) {
container.innerHTML = `
container.innerHTML = `
<div class="explorer-panel">
<div class="explorer-toolbar">
<input type="text" class="explorer-path" id="explorer-path" value="${currentDir || ''}" placeholder="Open a folder..." readonly>
@@ -11,60 +9,86 @@ function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir
</div>
`;
document.getElementById('explorer-browse')?.addEventListener('click', async () => {
const dir = await listDirectory(null); // null means open folder dialog
if (dir) {
document.getElementById('explorer-path').value = dir.path;
renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, dir.path);
}
});
if (currentDir) {
listDirectory(currentDir).then(dir => {
if (dir) renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, currentDir);
});
document.getElementById('explorer-browse')?.addEventListener('click', async () => {
const dir = await listDirectory(null); // null means open folder dialog
if (dir) {
document.getElementById('explorer-path').value = dir.path;
renderTree(
document.getElementById('explorer-tree'),
dir.entries,
listDirectory,
onFileOpen,
dir.path
);
}
});
if (currentDir) {
listDirectory(currentDir).then((dir) => {
if (dir)
renderTree(
document.getElementById('explorer-tree'),
dir.entries,
listDirectory,
onFileOpen,
currentDir
);
});
}
}
function renderTree(container, entries, listDirectory, onFileOpen, basePath) {
container.innerHTML = entries.map(entry => {
if (entry.isDirectory) {
return `<div class="tree-item tree-folder collapsed" data-path="${entry.path}">
function renderTree(container, entries, listDirectory, onFileOpen, _basePath) {
container.innerHTML = entries
.map((entry) => {
if (entry.isDirectory) {
return `<div class="tree-item tree-folder collapsed" data-path="${entry.path}">
<span class="tree-icon">&#x25B6;</span>
<span class="tree-name">${entry.name}</span>
<div class="tree-children"></div>
</div>`;
}
return `<div class="tree-item tree-file" data-path="${entry.path}">
}
return `<div class="tree-item tree-file" data-path="${entry.path}">
<span class="tree-icon">${getFileIcon(entry.name)}</span>
<span class="tree-name">${entry.name}</span>
</div>`;
}).join('');
})
.join('');
container.querySelectorAll('.tree-folder').forEach(el => {
el.querySelector('.tree-name').addEventListener('click', async () => {
const isCollapsed = el.classList.contains('collapsed');
if (isCollapsed) {
const dir = await listDirectory(el.dataset.path);
if (dir) {
const childContainer = el.querySelector('.tree-children');
renderTree(childContainer, dir.entries, listDirectory, onFileOpen, el.dataset.path);
}
}
el.classList.toggle('collapsed');
el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed') ? '\u25B6' : '\u25BC';
});
container.querySelectorAll('.tree-folder').forEach((el) => {
el.querySelector('.tree-name').addEventListener('click', async () => {
const isCollapsed = el.classList.contains('collapsed');
if (isCollapsed) {
const dir = await listDirectory(el.dataset.path);
if (dir) {
const childContainer = el.querySelector('.tree-children');
renderTree(childContainer, dir.entries, listDirectory, onFileOpen, el.dataset.path);
}
}
el.classList.toggle('collapsed');
el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed')
? '\u25B6'
: '\u25BC';
});
});
container.querySelectorAll('.tree-file').forEach(el => {
el.addEventListener('click', () => onFileOpen(el.dataset.path));
});
container.querySelectorAll('.tree-file').forEach((el) => {
el.addEventListener('click', () => onFileOpen(el.dataset.path));
});
}
function getFileIcon(filename) {
const ext = filename.split('.').pop().toLowerCase();
const icons = { md: '\u{1F4DD}', js: '\u{1F4DC}', json: '{}', html: '\u{1F310}', css: '\u{1F3A8}', py: '\u{1F40D}', pdf: '\u{1F4D5}', txt: '\u{1F4C4}' };
return icons[ext] || '\u{1F4C4}';
const ext = filename.split('.').pop().toLowerCase();
const icons = {
md: '\u{1F4DD}',
js: '\u{1F4DC}',
json: '{}',
html: '\u{1F310}',
css: '\u{1F3A8}',
py: '\u{1F40D}',
pdf: '\u{1F4D5}',
txt: '\u{1F4C4}',
};
return icons[ext] || '\u{1F4C4}';
}
module.exports = { renderExplorerPanel };
+55 -45
View File
@@ -1,5 +1,5 @@
function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gitLog }) {
container.innerHTML = `
function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, gitLog }) {
container.innerHTML = `
<div class="git-panel">
<div class="git-section">
<h4 class="git-section-title">Changes</h4>
@@ -19,66 +19,76 @@ function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gi
</div>
`;
loadGitStatus();
loadGitStatus();
async function loadGitStatus() {
const status = await gitStatus();
const changesEl = document.getElementById('git-changes');
if (!status || !changesEl) return;
async function loadGitStatus() {
const status = await gitStatus();
const changesEl = document.getElementById('git-changes');
if (!status || !changesEl) return;
if (status.error) {
changesEl.innerHTML = `<p class="git-info">${status.error}</p>`;
return;
}
if (status.error) {
changesEl.innerHTML = `<p class="git-info">${status.error}</p>`;
return;
}
const files = [
...status.modified.map(f => ({ file: f, status: 'M', color: '#f59e0b' })),
...status.not_added.map(f => ({ file: f, status: '?', color: '#6b7280' })),
...status.created.map(f => ({ file: f, status: 'A', color: '#10b981' })),
...status.deleted.map(f => ({ file: f, status: 'D', color: '#ef4444' })),
...status.staged.map(f => ({ file: f, status: 'S', color: '#3b82f6' })),
];
const files = [
...status.modified.map((f) => ({ file: f, status: 'M', color: '#f59e0b' })),
...status.not_added.map((f) => ({ file: f, status: '?', color: '#6b7280' })),
...status.created.map((f) => ({ file: f, status: 'A', color: '#10b981' })),
...status.deleted.map((f) => ({ file: f, status: 'D', color: '#ef4444' })),
...status.staged.map((f) => ({ file: f, status: 'S', color: '#3b82f6' })),
];
if (files.length === 0) {
changesEl.innerHTML = '<p class="git-info">No changes</p>';
} else {
changesEl.innerHTML = files.map(f => `
if (files.length === 0) {
changesEl.innerHTML = '<p class="git-info">No changes</p>';
} else {
changesEl.innerHTML = files
.map(
(f) => `
<div class="git-file" data-file="${f.file}">
<span class="git-file-status" style="color:${f.color}">${f.status}</span>
<span class="git-file-name">${f.file}</span>
<button class="git-stage-btn" data-file="${f.file}" title="Stage file">+</button>
</div>
`).join('');
`
)
.join('');
changesEl.querySelectorAll('.git-stage-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await gitStage([btn.dataset.file]);
loadGitStatus();
});
});
}
changesEl.querySelectorAll('.git-stage-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await gitStage([btn.dataset.file]);
loadGitStatus();
});
});
}
// Load log
const log = await gitLog();
const logEl = document.getElementById('git-log');
if (log && logEl) {
logEl.innerHTML = (log.all || []).slice(0, 10).map(entry => `
// Load log
const log = await gitLog();
const logEl = document.getElementById('git-log');
if (log && logEl) {
logEl.innerHTML =
(log.all || [])
.slice(0, 10)
.map(
(entry) => `
<div class="git-log-entry">
<div class="git-log-msg">${entry.message}</div>
<div class="git-log-meta">${entry.date?.substring(0, 10) || ''} &middot; ${entry.author_name || ''}</div>
</div>
`).join('') || '<p class="git-info">No commits</p>';
}
`
)
.join('') || '<p class="git-info">No commits</p>';
}
}
document.getElementById('git-commit-btn')?.addEventListener('click', async () => {
const msg = document.getElementById('git-commit-msg')?.value?.trim();
if (!msg) return;
await gitCommit(msg);
document.getElementById('git-commit-msg').value = '';
loadGitStatus();
});
document.getElementById('git-commit-btn')?.addEventListener('click', async () => {
const msg = document.getElementById('git-commit-msg')?.value?.trim();
if (!msg) return;
await gitCommit(msg);
document.getElementById('git-commit-msg').value = '';
loadGitStatus();
});
}
module.exports = { renderGitPanel };
+79 -73
View File
@@ -4,54 +4,54 @@
*/
function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHeadingClick }) {
container.innerHTML = `
container.innerHTML = `
<div class="outline-panel">
<div class="outline-list" id="outline-list"></div>
</div>
`;
const listEl = document.getElementById('outline-list');
let headings = [];
let activeLine = 1;
let debounceTimer = null;
const listEl = document.getElementById('outline-list');
let headings = [];
let activeLine = 1;
let debounceTimer = null;
function parseHeadings(content) {
const result = [];
if (!content) return result;
const lines = content.split('\n');
const regex = /^(#{1,6})\s+(.+)$/;
for (let i = 0; i < lines.length; i++) {
const match = regex.exec(lines[i]);
if (match) {
result.push({
level: match[1].length,
text: match[2].trim(),
line: i + 1
});
}
}
return result;
function parseHeadings(content) {
const result = [];
if (!content) return result;
const lines = content.split('\n');
const regex = /^(#{1,6})\s+(.+)$/;
for (let i = 0; i < lines.length; i++) {
const match = regex.exec(lines[i]);
if (match) {
result.push({
level: match[1].length,
text: match[2].trim(),
line: i + 1,
});
}
}
return result;
}
function findActiveHeading(currentLine) {
let active = null;
for (const h of headings) {
if (h.line <= currentLine) {
active = h;
} else {
break;
}
}
return active;
function findActiveHeading(currentLine) {
let active = null;
for (const h of headings) {
if (h.line <= currentLine) {
active = h;
} else {
break;
}
}
return active;
}
function renderHeadings() {
const content = getEditorContent();
headings = parseHeadings(content);
activeLine = getActiveLine();
function renderHeadings() {
const content = getEditorContent();
headings = parseHeadings(content);
activeLine = getActiveLine();
if (headings.length === 0) {
listEl.innerHTML = `
if (headings.length === 0) {
listEl.innerHTML = `
<div class="outline-empty">
<p>No headings found</p>
<p class="outline-hint"># Heading 1</p>
@@ -59,56 +59,62 @@ function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHead
<p class="outline-hint">### Heading 3</p>
</div>
`;
return;
}
return;
}
const activeHeading = findActiveHeading(activeLine);
const activeHeading = findActiveHeading(activeLine);
listEl.innerHTML = headings.map((h, idx) => `
listEl.innerHTML =
headings
.map(
(h, idx) => `
<div class="outline-item outline-level-${h.level}${activeHeading && h.line === activeHeading.line ? ' active' : ''}"
data-line="${h.line}" data-index="${idx}">
<span class="outline-text">${escapeHtml(h.text)}</span>
<span class="outline-badge">H${h.level}</span>
</div>
`).join('') + `<div class="outline-footer">${headings.length} heading${headings.length !== 1 ? 's' : ''}</div>`;
`
)
.join('') +
`<div class="outline-footer">${headings.length} heading${headings.length !== 1 ? 's' : ''}</div>`;
listEl.querySelectorAll('.outline-item').forEach(item => {
item.addEventListener('click', () => {
const line = parseInt(item.dataset.line, 10);
onHeadingClick(line);
});
});
}
listEl.querySelectorAll('.outline-item').forEach((item) => {
item.addEventListener('click', () => {
const line = parseInt(item.dataset.line, 10);
onHeadingClick(line);
});
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function refresh() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(renderHeadings, 300);
}
function refresh() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(renderHeadings, 300);
}
function setActiveHeading(line) {
activeLine = line;
const activeHeading = findActiveHeading(line);
function setActiveHeading(line) {
activeLine = line;
const activeHeading = findActiveHeading(line);
listEl.querySelectorAll('.outline-item').forEach(item => {
const itemLine = parseInt(item.dataset.line, 10);
if (activeHeading && itemLine === activeHeading.line) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
}
listEl.querySelectorAll('.outline-item').forEach((item) => {
const itemLine = parseInt(item.dataset.line, 10);
if (activeHeading && itemLine === activeHeading.line) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
}
container._refreshOutline = refresh;
container._setActiveHeading = setActiveHeading;
container._refreshOutline = refresh;
container._setActiveHeading = setActiveHeading;
renderHeadings();
renderHeadings();
}
module.exports = { renderOutlinePanel };
+42 -40
View File
@@ -1,50 +1,52 @@
class SidebarManager {
constructor() {
this.sidebar = document.getElementById('sidebar');
this.panelContent = document.getElementById('sidebar-panel-content');
this.panelTitle = document.querySelector('.sidebar-panel-title');
this.activePanel = null;
this.panels = new Map();
this.setupEventListeners();
}
constructor() {
this.sidebar = document.getElementById('sidebar');
this.panelContent = document.getElementById('sidebar-panel-content');
this.panelTitle = document.querySelector('.sidebar-panel-title');
this.activePanel = null;
this.panels = new Map();
this.setupEventListeners();
}
setupEventListeners() {
document.querySelectorAll('.sidebar-icon').forEach(btn => {
btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel));
});
document.querySelector('.sidebar-panel-close')?.addEventListener('click', () => this.collapse());
}
setupEventListeners() {
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel));
});
document
.querySelector('.sidebar-panel-close')
?.addEventListener('click', () => this.collapse());
}
registerPanel(name, { title, render }) {
this.panels.set(name, { title, render });
}
registerPanel(name, { title, render }) {
this.panels.set(name, { title, render });
}
togglePanel(name) {
if (this.activePanel === name) {
this.collapse();
} else {
this.expand(name);
}
togglePanel(name) {
if (this.activePanel === name) {
this.collapse();
} else {
this.expand(name);
}
}
expand(name) {
const panel = this.panels.get(name);
if (!panel) return;
this.sidebar.classList.remove('collapsed');
this.panelTitle.textContent = panel.title;
this.panelContent.innerHTML = '';
panel.render(this.panelContent);
this.activePanel = name;
document.querySelectorAll('.sidebar-icon').forEach(btn => {
btn.classList.toggle('active', btn.dataset.panel === name);
});
}
expand(name) {
const panel = this.panels.get(name);
if (!panel) return;
this.sidebar.classList.remove('collapsed');
this.panelTitle.textContent = panel.title;
this.panelContent.innerHTML = '';
panel.render(this.panelContent);
this.activePanel = name;
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.panel === name);
});
}
collapse() {
this.sidebar.classList.add('collapsed');
this.activePanel = null;
document.querySelectorAll('.sidebar-icon').forEach(btn => btn.classList.remove('active'));
}
collapse() {
this.sidebar.classList.add('collapsed');
this.activePanel = null;
document.querySelectorAll('.sidebar-icon').forEach((btn) => btn.classList.remove('active'));
}
}
module.exports = { SidebarManager };
+52 -40
View File
@@ -1,5 +1,5 @@
function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippet, onInsert }) {
container.innerHTML = `
container.innerHTML = `
<div class="snippets-panel">
<div class="snippets-toolbar">
<input type="text" class="snippets-search" id="snippets-search" placeholder="Search snippets...">
@@ -9,22 +9,29 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
</div>
`;
let snippets = [];
let snippets = [];
async function loadSnippets() {
snippets = await getSnippets() || [];
renderList(document.getElementById('snippets-search')?.value || '');
}
async function loadSnippets() {
snippets = (await getSnippets()) || [];
renderList(document.getElementById('snippets-search')?.value || '');
}
function renderList(query) {
const list = document.getElementById('snippets-list');
if (!list) return;
function renderList(query) {
const list = document.getElementById('snippets-list');
if (!list) return;
const filtered = query
? snippets.filter(s => s.name.toLowerCase().includes(query.toLowerCase()) || (s.language || '').toLowerCase().includes(query.toLowerCase()))
: snippets;
const filtered = query
? snippets.filter(
(s) =>
s.name.toLowerCase().includes(query.toLowerCase()) ||
(s.language || '').toLowerCase().includes(query.toLowerCase())
)
: snippets;
list.innerHTML = filtered.length ? filtered.map(s => `
list.innerHTML = filtered.length
? filtered
.map(
(s) => `
<div class="snippet-item" data-id="${s.id}">
<div class="snippet-header">
<span class="snippet-name">${s.name}</span>
@@ -36,36 +43,41 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
<button class="snippet-delete" data-id="${s.id}" title="Delete">&times;</button>
</div>
</div>
`).join('') : '<p class="git-info">No snippets yet. Click + to add one.</p>';
`
)
.join('')
: '<p class="git-info">No snippets yet. Click + to add one.</p>';
list.querySelectorAll('.snippet-insert').forEach(btn => {
btn.addEventListener('click', () => {
const s = snippets.find(sn => sn.id === btn.dataset.id);
if (s) onInsert(s.code);
});
});
list.querySelectorAll('.snippet-delete').forEach(btn => {
btn.addEventListener('click', async () => {
await deleteSnippet(btn.dataset.id);
loadSnippets();
});
});
}
document.getElementById('snippets-search')?.addEventListener('input', (e) => renderList(e.target.value));
document.getElementById('snippets-add')?.addEventListener('click', () => {
const name = prompt('Snippet name:');
if (!name) return;
const language = prompt('Language (e.g., javascript, python, html):') || 'text';
const code = prompt('Paste your code snippet:');
if (!code) return;
saveSnippet({ id: Date.now().toString(), name, language, code }).then(() => loadSnippets());
list.querySelectorAll('.snippet-insert').forEach((btn) => {
btn.addEventListener('click', () => {
const s = snippets.find((sn) => sn.id === btn.dataset.id);
if (s) onInsert(s.code);
});
});
loadSnippets();
list.querySelectorAll('.snippet-delete').forEach((btn) => {
btn.addEventListener('click', async () => {
await deleteSnippet(btn.dataset.id);
loadSnippets();
});
});
}
document
.getElementById('snippets-search')
?.addEventListener('input', (e) => renderList(e.target.value));
document.getElementById('snippets-add')?.addEventListener('click', () => {
const name = prompt('Snippet name:');
if (!name) return;
const language = prompt('Language (e.g., javascript, python, html):') || 'text';
const code = prompt('Paste your code snippet:');
if (!code) return;
saveSnippet({ id: Date.now().toString(), name, language, code }).then(() => loadSnippets());
});
loadSnippets();
}
module.exports = { renderSnippetsPanel };
+24 -19
View File
@@ -1,34 +1,39 @@
const fs = require('fs');
const path = require('path');
const templates = [
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
{ name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' },
{ name: 'Technical Spec', file: 'technical-spec.md', description: 'Requirements and architecture' },
{ name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
{ name: 'README', file: 'readme.md', description: 'Project documentation' },
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
{ name: 'API Docs', file: 'api-docs.md', description: 'API endpoint documentation' },
{ name: 'Tutorial', file: 'tutorial.md', description: 'Step-by-step guide' },
{ name: 'Release Notes', file: 'release-notes.md', description: 'Version release summary' },
{ name: 'Comparison', file: 'comparison.md', description: 'Feature comparison table' },
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
{ name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' },
{
name: 'Technical Spec',
file: 'technical-spec.md',
description: 'Requirements and architecture',
},
{ name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
{ name: 'README', file: 'readme.md', description: 'Project documentation' },
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
{ name: 'API Docs', file: 'api-docs.md', description: 'API endpoint documentation' },
{ name: 'Tutorial', file: 'tutorial.md', description: 'Step-by-step guide' },
{ name: 'Release Notes', file: 'release-notes.md', description: 'Version release summary' },
{ name: 'Comparison', file: 'comparison.md', description: 'Feature comparison table' },
];
function renderTemplatesPanel(container, onSelect) {
container.innerHTML = `
container.innerHTML = `
<div class="panel-list">
${templates.map(t => `
${templates
.map(
(t) => `
<div class="panel-list-item template-item" data-file="${t.file}">
<div class="panel-list-item-title">${t.name}</div>
<div class="panel-list-item-desc">${t.description}</div>
</div>
`).join('')}
`
)
.join('')}
</div>
`;
container.querySelectorAll('.template-item').forEach(el => {
el.addEventListener('click', () => onSelect(el.dataset.file));
});
container.querySelectorAll('.template-item').forEach((el) => {
el.addEventListener('click', () => onSelect(el.dataset.file));
});
}
module.exports = { renderTemplatesPanel, templates };
+21 -6
View File
@@ -675,18 +675,33 @@ body.theme-concreteinfo-dark .modal-footer {
Animations
============================================ */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.animate-fade-in {
+331 -197
View File
@@ -60,7 +60,15 @@
/* Base styles - Reset is in styles.css */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
overflow: hidden;
height: 100vh;
/* Background controlled by themes - do not set here */
@@ -68,9 +76,15 @@ body {
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.container {
@@ -345,7 +359,9 @@ body {
}
/* Preview Styles - Modern Typography */
.preview-content h1, .preview-content h2, .preview-content h3 {
.preview-content h1,
.preview-content h2,
.preview-content h3 {
/* Color controlled by theme */
font-weight: 700;
margin-top: 28px;
@@ -695,11 +711,7 @@ body {
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: shimmer 2s infinite;
}
@@ -798,7 +810,9 @@ body {
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
transition:
width 0.6s,
height 0.6s;
}
.toolbar button:hover::before,
@@ -1046,7 +1060,7 @@ body.theme-paper .pdf-viewer-toolbar,
body.theme-rosepine-dawn .pdf-viewer-toolbar {
background: linear-gradient(180deg, #fafafa 0%, #f0f0f0 100%) !important;
border-bottom: 1px solid #d0d0d0 !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
body.theme-light .pdf-viewer-toolbar button,
@@ -1061,7 +1075,7 @@ body.theme-rosepine-dawn .pdf-viewer-toolbar button {
background: #fff !important;
border: 1px solid #ccc !important;
color: #333 !important;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
body.theme-light .pdf-viewer-toolbar button:hover,
@@ -1444,7 +1458,7 @@ body.theme-rosepine-dawn .pdf-viewer {
.export-dialog,
.batch-dialog,
.editor-pane,
[id^="editor-pane-"],
[id^='editor-pane-'],
.new-tab-button,
.tab-close {
display: none !important;
@@ -1452,9 +1466,9 @@ body.theme-rosepine-dawn .pdf-viewer {
/* Show preview content */
.preview-pane,
[id^="preview-pane-"],
[id^='preview-pane-'],
.preview-content,
[id^="preview-"] {
[id^='preview-'] {
display: block !important;
width: 100% !important;
padding: 20px !important;
@@ -1491,9 +1505,9 @@ body.theme-rosepine-dawn .pdf-viewer {
body.printing-no-styles .tab-content,
body.printing-no-styles .pane,
body.printing-no-styles .preview-pane,
body.printing-no-styles [id^="preview-pane-"],
body.printing-no-styles [id^='preview-pane-'],
body.printing-no-styles .preview-content,
body.printing-no-styles [id^="preview-"] {
body.printing-no-styles [id^='preview-'] {
background: white !important;
color: #000 !important;
}
@@ -1539,27 +1553,27 @@ body.theme-rosepine-dawn .pdf-viewer {
/* Common dark theme styles for preview content */
body.theme-dark .preview-content,
body.theme-dark [id^="preview-"],
body.theme-dark [id^='preview-'],
body.theme-one-dark .preview-content,
body.theme-one-dark [id^="preview-"],
body.theme-one-dark [id^='preview-'],
body.theme-dracula .preview-content,
body.theme-dracula [id^="preview-"],
body.theme-dracula [id^='preview-'],
body.theme-nord .preview-content,
body.theme-nord [id^="preview-"],
body.theme-nord [id^='preview-'],
body.theme-tokyo-night .preview-content,
body.theme-tokyo-night [id^="preview-"],
body.theme-tokyo-night [id^='preview-'],
body.theme-palenight .preview-content,
body.theme-palenight [id^="preview-"],
body.theme-palenight [id^='preview-'],
body.theme-ayu-dark .preview-content,
body.theme-ayu-dark [id^="preview-"],
body.theme-ayu-dark [id^='preview-'],
body.theme-ayu-mirage .preview-content,
body.theme-ayu-mirage [id^="preview-"],
body.theme-ayu-mirage [id^='preview-'],
body.theme-oceanic-next .preview-content,
body.theme-oceanic-next [id^="preview-"],
body.theme-oceanic-next [id^='preview-'],
body.theme-gruvbox-dark .preview-content,
body.theme-gruvbox-dark [id^="preview-"],
body.theme-gruvbox-dark [id^='preview-'],
body.theme-cobalt2 .preview-content,
body.theme-cobalt2 [id^="preview-"] {
body.theme-cobalt2 [id^='preview-'] {
color: #e6e6e6;
background: transparent;
}
@@ -2145,233 +2159,353 @@ body.theme-rosepine-dawn .batch-dialog-footer {
Command Palette (Ctrl+Shift+P)
======================================== */
.command-palette-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
justify-content: center;
padding-top: 80px;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
justify-content: center;
padding-top: 80px;
}
.command-palette-overlay.hidden {
display: none;
display: none;
}
.command-palette {
width: 560px;
max-height: 400px;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
width: 560px;
max-height: 400px;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
}
.command-palette-input {
width: 100%;
padding: 14px 18px;
border: none;
outline: none;
font-size: 15px;
border-bottom: 1px solid #e5e7eb;
font-family: inherit;
width: 100%;
padding: 14px 18px;
border: none;
outline: none;
font-size: 15px;
border-bottom: 1px solid #e5e7eb;
font-family: inherit;
}
.command-palette-results {
overflow-y: auto;
max-height: 340px;
overflow-y: auto;
max-height: 340px;
}
.command-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 18px;
cursor: pointer;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 18px;
cursor: pointer;
font-size: 14px;
}
.command-item:hover,
.command-item.selected {
background: #f3f4f6;
background: #f3f4f6;
}
.command-label strong {
color: #5661b3;
color: #5661b3;
}
.command-shortcut {
font-size: 12px;
color: #9ca3af;
background: #f3f4f6;
padding: 2px 8px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #9ca3af;
background: #f3f4f6;
padding: 2px 8px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
}
/* Dark theme command palette */
body[class*="dark"] .command-palette {
background: #1e1e1e;
border: 1px solid #333;
body[class*='dark'] .command-palette {
background: #1e1e1e;
border: 1px solid #333;
}
body[class*="dark"] .command-palette-input {
background: #1e1e1e;
color: #eee;
border-color: #333;
body[class*='dark'] .command-palette-input {
background: #1e1e1e;
color: #eee;
border-color: #333;
}
body[class*="dark"] .command-item:hover,
body[class*="dark"] .command-item.selected {
background: #2d2d2d;
body[class*='dark'] .command-item:hover,
body[class*='dark'] .command-item.selected {
background: #2d2d2d;
}
body[class*="dark"] .command-shortcut {
background: #333;
color: #888;
body[class*='dark'] .command-shortcut {
background: #333;
color: #888;
}
/* ========================================
Breadcrumb Bar
======================================== */
.breadcrumb-bar {
padding: 4px 12px;
font-size: 12px;
color: var(--gray-500, #6b7280);
border-bottom: 1px solid var(--gray-200, #e5e7eb);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
background: var(--gray-50, #f9fafb);
font-family: 'JetBrains Mono', monospace;
padding: 4px 12px;
font-size: 12px;
color: var(--gray-500, #6b7280);
border-bottom: 1px solid var(--gray-200, #e5e7eb);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
background: var(--gray-50, #f9fafb);
font-family: 'JetBrains Mono', monospace;
}
body[class*="dark"] .breadcrumb-bar {
background: #1a1a1a;
color: #888;
border-color: #333;
body[class*='dark'] .breadcrumb-bar {
background: #1a1a1a;
color: #888;
border-color: #333;
}
/* Print Preview Dialog */
.print-preview-dialog {
width: 90vw;
max-width: 1200px;
height: 80vh;
display: flex;
flex-direction: column;
width: 90vw;
max-width: 1200px;
height: 80vh;
display: flex;
flex-direction: column;
}
.print-preview-body {
display: flex;
flex: 1;
overflow: hidden;
display: flex;
flex: 1;
overflow: hidden;
}
.print-preview-sidebar {
width: 260px;
padding: 16px;
border-right: 1px solid var(--gray-200, #e5e7eb);
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
width: 260px;
padding: 16px;
border-right: 1px solid var(--gray-200, #e5e7eb);
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.print-preview-content {
flex: 1;
padding: 16px;
background: #e5e7eb;
display: flex;
justify-content: center;
overflow: auto;
flex: 1;
padding: 16px;
background: #e5e7eb;
display: flex;
justify-content: center;
overflow: auto;
}
.print-preview-content iframe {
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-radius: 4px;
}
.print-option-group {
display: flex;
flex-direction: column;
gap: 6px;
display: flex;
flex-direction: column;
gap: 6px;
}
.print-option-group label {
font-size: 13px;
font-weight: 500;
color: var(--gray-600, #4b5563);
font-size: 13px;
font-weight: 500;
color: var(--gray-600, #4b5563);
}
.print-option-group select,
.print-option-group input[type="text"] {
padding: 8px 10px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px;
font-size: 13px;
background: white;
.print-option-group input[type='text'] {
padding: 8px 10px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px;
font-size: 13px;
background: white;
}
.scale-control {
display: flex;
align-items: center;
gap: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.scale-control input[type="range"] {
flex: 1;
.scale-control input[type='range'] {
flex: 1;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.print-range-input {
margin-top: 6px;
margin-top: 6px;
}
.print-actions {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 16px;
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 16px;
}
.btn-primary {
flex: 1;
padding: 10px;
background: var(--primary-dark, #5661b3);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
flex: 1;
padding: 10px;
background: var(--primary-dark, #5661b3);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
opacity: 0.9;
}
.btn-secondary {
flex: 1;
padding: 10px;
background: var(--gray-100, #f3f4f6);
color: var(--gray-700, #374151);
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px;
font-size: 14px;
cursor: pointer;
flex: 1;
padding: 10px;
background: var(--gray-100, #f3f4f6);
color: var(--gray-700, #374151);
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
/* Writing Analytics Modal */
.analytics-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; z-index:10000; animation:analyticsFadeIn 0.2s ease; }
.analytics-modal { background:var(--bg-primary,#fff); border-radius:12px; width:520px; max-height:80vh; overflow-y:auto; box-shadow:0 20px 60px rgba(0,0,0,0.3); animation:analyticsSlideUp 0.3s ease; }
.analytics-header { display:flex; justify-content:space-between; align-items:center; padding:20px 24px; border-bottom:1px solid var(--border-color,#e5e7eb); }
.analytics-header h2 { margin:0; font-size:18px; font-weight:600; color:var(--text-primary); }
.analytics-close { background:none; border:none; font-size:24px; cursor:pointer; color:var(--text-muted); padding:4px 8px; border-radius:4px; }
.analytics-close:hover { background:var(--bg-tertiary); color:var(--text-primary); }
.analytics-body { padding:16px 24px 24px; }
.analytics-section { margin-bottom:20px; }
.analytics-section h3 { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-muted,#9ca3af); margin:0 0 10px; }
.analytics-row { display:flex; justify-content:space-between; align-items:baseline; padding:6px 0; font-size:14px; }
.analytics-label { color:var(--text-secondary,#6b7280); }
.analytics-value { font-weight:500; color:var(--text-primary); }
.analytics-value small { color:var(--text-muted); font-weight:400; margin-left:6px; }
.readability-meter { height:4px; background:var(--bg-tertiary,#f3f4f6); border-radius:2px; margin-top:8px; overflow:hidden; }
.readability-fill { height:100%; background:linear-gradient(90deg,#ef4444,#f59e0b,#10b981); border-radius:2px; transition:width 0.5s ease; }
.word-cloud { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
.word-tag { background:var(--bg-tertiary,#f3f4f6); padding:4px 10px; border-radius:12px; font-size:13px; color:var(--text-secondary); }
.word-tag small { opacity:0.5; font-size:10px; margin-left:2px; }
.analytics-longest { flex-direction:column; gap:4px; }
.analytics-sentence-preview { font-style:italic; font-size:13px; color:var(--text-secondary); font-weight:400; }
@keyframes analyticsFadeIn { from{opacity:0} to{opacity:1} }
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} }
body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); }
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
.analytics-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: analyticsFadeIn 0.2s ease;
}
.analytics-modal {
background: var(--bg-primary, #fff);
border-radius: 12px;
width: 520px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: analyticsSlideUp 0.3s ease;
}
.analytics-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.analytics-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.analytics-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-muted);
padding: 4px 8px;
border-radius: 4px;
}
.analytics-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.analytics-body {
padding: 16px 24px 24px;
}
.analytics-section {
margin-bottom: 20px;
}
.analytics-section h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #9ca3af);
margin: 0 0 10px;
}
.analytics-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
font-size: 14px;
}
.analytics-label {
color: var(--text-secondary, #6b7280);
}
.analytics-value {
font-weight: 500;
color: var(--text-primary);
}
.analytics-value small {
color: var(--text-muted);
font-weight: 400;
margin-left: 6px;
}
.readability-meter {
height: 4px;
background: var(--bg-tertiary, #f3f4f6);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
}
.readability-fill {
height: 100%;
background: linear-gradient(90deg, #ef4444, #f59e0b, #10b981);
border-radius: 2px;
transition: width 0.5s ease;
}
.word-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.word-tag {
background: var(--bg-tertiary, #f3f4f6);
padding: 4px 10px;
border-radius: 12px;
font-size: 13px;
color: var(--text-secondary);
}
.word-tag small {
opacity: 0.5;
font-size: 10px;
margin-left: 2px;
}
.analytics-longest {
flex-direction: column;
gap: 4px;
}
.analytics-sentence-preview {
font-style: italic;
font-size: 13px;
color: var(--text-secondary);
font-weight: 400;
}
@keyframes analyticsFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes analyticsSlideUp {
from {
transform: translateY(16px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
body[class*='dark'] .analytics-modal {
background: var(--gray-800, #1f2937);
}
body[class*='dark'] .word-tag {
background: var(--gray-700, #374151);
}
/* ========================================
Interactive PDF Thumbnail Sidebar Styles
@@ -2449,7 +2583,7 @@ body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
}
.pdf-thumbnail-card.marked-delete::after {
content: "DELETE";
content: 'DELETE';
position: absolute;
top: 50%;
left: 50%;
@@ -2604,21 +2738,21 @@ body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
}
/* Dark mode adjustments */
body[class*="dark"] .pdf-thumbnail-card {
body[class*='dark'] .pdf-thumbnail-card {
background: var(--gray-800, #1f2937);
border-color: var(--gray-700, #374151);
}
body[class*="dark"] .pdf-thumbnail-header .badge {
body[class*='dark'] .pdf-thumbnail-header .badge {
background: var(--gray-700, #374151);
color: var(--gray-200);
}
body[class*="dark"] .canvas-container {
body[class*='dark'] .canvas-container {
background: var(--gray-900, #111827);
border-color: var(--gray-700);
}
body[class*="dark"] .btn-rotate-thumbnail,
body[class*="dark"] .btn-reorder-left,
body[class*="dark"] .btn-reorder-right {
body[class*='dark'] .btn-rotate-thumbnail,
body[class*='dark'] .btn-reorder-left,
body[class*='dark'] .btn-reorder-right {
background: var(--gray-700, #374151);
border-color: var(--gray-600);
color: var(--gray-200);
+415 -196
View File
@@ -1,304 +1,523 @@
/* Sidebar */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
.sidebar {
display: flex;
flex-shrink: 0;
height: 100%;
transition: width 0.2s ease;
display: flex;
flex-shrink: 0;
height: 100%;
transition: width 0.2s ease;
}
.sidebar.collapsed {
width: 48px;
width: 48px;
}
.sidebar:not(.collapsed) {
width: 328px; /* 48px icons + 280px panel */
width: 328px; /* 48px icons + 280px panel */
}
.sidebar-icons {
display: flex;
flex-direction: column;
width: 48px;
background: var(--gray-100, #f3f4f6);
border-right: 1px solid var(--gray-200, #e5e7eb);
padding: 8px 0;
gap: 4px;
align-items: center;
display: flex;
flex-direction: column;
width: 48px;
background: var(--gray-100, #f3f4f6);
border-right: 1px solid var(--gray-200, #e5e7eb);
padding: 8px 0;
gap: 4px;
align-items: center;
}
.sidebar-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: var(--gray-500, #6b7280);
transition: all 0.15s ease;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: var(--gray-500, #6b7280);
transition: all 0.15s ease;
}
.sidebar-icon:hover {
background: var(--gray-200, #e5e7eb);
color: var(--gray-700, #374151);
background: var(--gray-200, #e5e7eb);
color: var(--gray-700, #374151);
}
.sidebar-icon.active {
background: var(--gray-200, #e5e7eb);
color: var(--primary-dark, #5661b3);
box-shadow: inset 3px 0 0 var(--primary-dark, #5661b3);
background: var(--gray-200, #e5e7eb);
color: var(--primary-dark, #5661b3);
box-shadow: inset 3px 0 0 var(--primary-dark, #5661b3);
}
.sidebar-icon:focus-visible {
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
}
.sidebar-panel {
width: 280px;
background: var(--gray-50, #f9fafb);
border-right: 1px solid var(--gray-200, #e5e7eb);
display: flex;
flex-direction: column;
overflow: hidden;
width: 280px;
background: var(--gray-50, #f9fafb);
border-right: 1px solid var(--gray-200, #e5e7eb);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar.collapsed .sidebar-panel {
display: none;
display: none;
}
.sidebar-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--gray-200, #e5e7eb);
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-600, #4b5563);
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--gray-200, #e5e7eb);
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-600, #4b5563);
}
.sidebar-panel-close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--gray-400, #9ca3af);
padding: 0 4px;
border-radius: 4px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--gray-400, #9ca3af);
padding: 0 4px;
border-radius: 4px;
}
.sidebar-panel-close:hover {
background: var(--gray-200, #e5e7eb);
color: var(--gray-600, #4b5563);
background: var(--gray-200, #e5e7eb);
color: var(--gray-600, #4b5563);
}
.sidebar-panel-close:focus-visible {
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
}
.sidebar-panel-content {
flex: 1;
overflow-y: auto;
padding: 12px;
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* Dark theme support */
body[class*="dark"] .sidebar-icons,
body[class*="dark"] .sidebar-panel {
background: #1e1e1e;
border-color: #333;
body[class*='dark'] .sidebar-icons,
body[class*='dark'] .sidebar-panel {
background: #1e1e1e;
border-color: #333;
}
body[class*="dark"] .sidebar-icon {
color: #888;
body[class*='dark'] .sidebar-icon {
color: #888;
}
body[class*="dark"] .sidebar-icon:hover {
background: #333;
color: #ccc;
body[class*='dark'] .sidebar-icon:hover {
background: #333;
color: #ccc;
}
body[class*="dark"] .sidebar-icon.active {
background: #333;
color: #8b9aff;
box-shadow: inset 3px 0 0 #8b9aff;
body[class*='dark'] .sidebar-icon.active {
background: #333;
color: #8b9aff;
box-shadow: inset 3px 0 0 #8b9aff;
}
body[class*="dark"] .sidebar-panel-header {
border-color: #333;
color: #ccc;
body[class*='dark'] .sidebar-panel-header {
border-color: #333;
color: #ccc;
}
/* Panel list items (templates, etc.) */
.panel-list-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
}
.panel-list-item:hover {
background: var(--gray-200, #e5e7eb);
background: var(--gray-200, #e5e7eb);
}
.panel-list-item-title {
font-size: 13px;
font-weight: 500;
color: var(--gray-800, #1f2937);
font-size: 13px;
font-weight: 500;
color: var(--gray-800, #1f2937);
}
.panel-list-item-desc {
font-size: 11px;
color: var(--gray-500, #6b7280);
margin-top: 2px;
font-size: 11px;
color: var(--gray-500, #6b7280);
margin-top: 2px;
}
body[class*="dark"] .panel-list-item:hover {
background: #333;
body[class*='dark'] .panel-list-item:hover {
background: #333;
}
body[class*="dark"] .panel-list-item-title {
color: #ddd;
body[class*='dark'] .panel-list-item-title {
color: #ddd;
}
body[class*="dark"] .panel-list-item-desc {
color: #888;
body[class*='dark'] .panel-list-item-desc {
color: #888;
}
/* Explorer Panel */
.explorer-toolbar {
display: flex;
gap: 4px;
margin-bottom: 8px;
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.explorer-path {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
font-size: 12px;
background: white;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
padding: 6px 8px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
font-size: 12px;
background: white;
overflow: hidden;
text-overflow: ellipsis;
}
.explorer-browse-btn {
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
background: white;
cursor: pointer;
padding: 4px 8px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
background: white;
cursor: pointer;
padding: 4px 8px;
}
.tree-item {
padding: 3px 0;
cursor: pointer;
font-size: 13px;
user-select: none;
padding: 3px 0;
cursor: pointer;
font-size: 13px;
user-select: none;
}
.tree-item:hover {
background: var(--gray-100, #f3f4f6);
border-radius: 4px;
background: var(--gray-100, #f3f4f6);
border-radius: 4px;
}
.tree-icon {
margin-right: 4px;
font-size: 12px;
margin-right: 4px;
font-size: 12px;
}
.tree-children {
padding-left: 16px;
padding-left: 16px;
}
.tree-folder.collapsed .tree-children {
display: none;
display: none;
}
.tree-name {
font-size: 13px;
font-size: 13px;
}
/* Git Panel */
.git-section { margin-bottom: 16px; }
.git-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gray-500); margin-bottom: 8px; }
.git-file { display: flex; align-items: center; padding: 4px 6px; border-radius: 4px; font-size: 13px; gap: 6px; }
.git-file:hover { background: var(--gray-100, #f3f4f6); }
.git-file-status { font-weight: 700; font-family: monospace; width: 16px; text-align: center; }
.git-file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.git-stage-btn { border: none; background: var(--gray-200); border-radius: 4px; cursor: pointer; font-size: 14px; width: 22px; height: 22px; }
.git-commit-input { width: 100%; padding: 8px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; font-family: inherit; resize: vertical; box-sizing: border-box; }
.git-commit-btn { width: 100%; margin-top: 8px; padding: 8px; background: var(--primary-dark, #5661b3); color: white; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; }
.git-commit-btn:hover { opacity: 0.9; }
.git-log-entry { padding: 6px 0; border-bottom: 1px solid var(--gray-100); }
.git-log-msg { font-size: 13px; }
.git-log-meta { font-size: 11px; color: var(--gray-500); margin-top: 2px; }
.git-info { font-size: 13px; color: var(--gray-500); padding: 8px 0; }
.git-loading { font-size: 13px; color: var(--gray-400); }
.git-section {
margin-bottom: 16px;
}
.git-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-500);
margin-bottom: 8px;
}
.git-file {
display: flex;
align-items: center;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
gap: 6px;
}
.git-file:hover {
background: var(--gray-100, #f3f4f6);
}
.git-file-status {
font-weight: 700;
font-family: monospace;
width: 16px;
text-align: center;
}
.git-file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-stage-btn {
border: none;
background: var(--gray-200);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
width: 22px;
height: 22px;
}
.git-commit-input {
width: 100%;
padding: 8px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
resize: vertical;
box-sizing: border-box;
}
.git-commit-btn {
width: 100%;
margin-top: 8px;
padding: 8px;
background: var(--primary-dark, #5661b3);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.git-commit-btn:hover {
opacity: 0.9;
}
.git-log-entry {
padding: 6px 0;
border-bottom: 1px solid var(--gray-100);
}
.git-log-msg {
font-size: 13px;
}
.git-log-meta {
font-size: 11px;
color: var(--gray-500);
margin-top: 2px;
}
.git-info {
font-size: 13px;
color: var(--gray-500);
padding: 8px 0;
}
.git-loading {
font-size: 13px;
color: var(--gray-400);
}
/* Snippets Panel */
.snippets-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
.snippets-search { flex: 1; padding: 6px 10px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; }
.snippets-add-btn { width: 32px; border: 1px solid var(--gray-300); border-radius: 6px; background: white; font-size: 18px; cursor: pointer; }
.snippet-item { padding: 8px; border: 1px solid var(--gray-200); border-radius: 6px; margin-bottom: 6px; }
.snippet-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.snippet-name { font-size: 13px; font-weight: 500; }
.snippet-lang { font-size: 11px; background: var(--gray-100); padding: 2px 6px; border-radius: 4px; color: var(--gray-500); }
.snippet-preview { font-size: 12px; background: var(--gray-50); padding: 6px; border-radius: 4px; margin: 4px 0; overflow: hidden; max-height: 60px; }
.snippet-preview code { font-family: 'JetBrains Mono', monospace; }
.snippet-actions { display: flex; gap: 4px; }
.snippet-insert { font-size: 12px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; background: white; cursor: pointer; }
.snippet-delete { font-size: 14px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; background: white; cursor: pointer; color: #ef4444; }
.snippets-toolbar {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.snippets-search {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 13px;
}
.snippets-add-btn {
width: 32px;
border: 1px solid var(--gray-300);
border-radius: 6px;
background: white;
font-size: 18px;
cursor: pointer;
}
.snippet-item {
padding: 8px;
border: 1px solid var(--gray-200);
border-radius: 6px;
margin-bottom: 6px;
}
.snippet-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.snippet-name {
font-size: 13px;
font-weight: 500;
}
.snippet-lang {
font-size: 11px;
background: var(--gray-100);
padding: 2px 6px;
border-radius: 4px;
color: var(--gray-500);
}
.snippet-preview {
font-size: 12px;
background: var(--gray-50);
padding: 6px;
border-radius: 4px;
margin: 4px 0;
overflow: hidden;
max-height: 60px;
}
.snippet-preview code {
font-family: 'JetBrains Mono', monospace;
}
.snippet-actions {
display: flex;
gap: 4px;
}
.snippet-insert {
font-size: 12px;
padding: 4px 8px;
border: 1px solid var(--gray-300);
border-radius: 4px;
background: white;
cursor: pointer;
}
.snippet-delete {
font-size: 14px;
padding: 4px 8px;
border: 1px solid var(--gray-300);
border-radius: 4px;
background: white;
cursor: pointer;
color: #ef4444;
}
/* Dark theme for sidebar panels */
body[class*="dark"] .explorer-path,
body[class*="dark"] .explorer-browse-btn,
body[class*="dark"] .snippets-search,
body[class*="dark"] .snippets-add-btn,
body[class*="dark"] .snippet-insert,
body[class*="dark"] .snippet-delete {
background: #2d2d2d;
border-color: #444;
color: #ccc;
body[class*='dark'] .explorer-path,
body[class*='dark'] .explorer-browse-btn,
body[class*='dark'] .snippets-search,
body[class*='dark'] .snippets-add-btn,
body[class*='dark'] .snippet-insert,
body[class*='dark'] .snippet-delete {
background: #2d2d2d;
border-color: #444;
color: #ccc;
}
body[class*="dark"] .git-commit-input {
background: #2d2d2d;
border-color: #444;
color: #ccc;
body[class*='dark'] .git-commit-input {
background: #2d2d2d;
border-color: #444;
color: #ccc;
}
body[class*="dark"] .snippet-item {
border-color: #444;
body[class*='dark'] .snippet-item {
border-color: #444;
}
body[class*="dark"] .snippet-preview {
background: #2d2d2d;
body[class*='dark'] .snippet-preview {
background: #2d2d2d;
}
body[class*="dark"] .tree-item:hover,
body[class*="dark"] .git-file:hover {
background: #333;
body[class*='dark'] .tree-item:hover,
body[class*='dark'] .git-file:hover {
background: #333;
}
/* Outline Panel */
.outline-panel { display: flex; flex-direction: column; height: 100%; }
.outline-list { flex: 1; overflow-y: auto; padding: 4px 0; }
.outline-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; cursor: pointer; font-size: 13px; color: var(--text-secondary, #6b7280); transition: background 0.15s, color 0.15s; border-left: 2px solid transparent; }
.outline-item:hover { background: var(--bg-tertiary, #f3f4f6); color: var(--text-primary, #1f2937); }
.outline-item.active { color: var(--accent-blue, #3b82f6); background: rgba(59, 130, 246, 0.08); border-left-color: var(--accent-blue, #3b82f6); font-weight: 600; }
.outline-level-1 { padding-left: 12px; }
.outline-level-2 { padding-left: 24px; }
.outline-level-3 { padding-left: 36px; }
.outline-level-4 { padding-left: 48px; }
.outline-level-5 { padding-left: 56px; }
.outline-level-6 { padding-left: 64px; }
.outline-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.outline-badge { font-size: 10px; opacity: 0.5; margin-left: 8px; flex-shrink: 0; }
.outline-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-muted, #9ca3af); text-align: center; }
.outline-empty p { margin: 4px 0; }
.outline-hint { font-family: monospace; font-size: 12px; opacity: 0.7; }
.outline-footer { padding: 8px 12px; border-top: 1px solid var(--border-color, #e5e7eb); font-size: 11px; color: var(--text-muted, #9ca3af); }
.outline-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.outline-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.outline-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary, #6b7280);
transition:
background 0.15s,
color 0.15s;
border-left: 2px solid transparent;
}
.outline-item:hover {
background: var(--bg-tertiary, #f3f4f6);
color: var(--text-primary, #1f2937);
}
.outline-item.active {
color: var(--accent-blue, #3b82f6);
background: rgba(59, 130, 246, 0.08);
border-left-color: var(--accent-blue, #3b82f6);
font-weight: 600;
}
.outline-level-1 {
padding-left: 12px;
}
.outline-level-2 {
padding-left: 24px;
}
.outline-level-3 {
padding-left: 36px;
}
.outline-level-4 {
padding-left: 48px;
}
.outline-level-5 {
padding-left: 56px;
}
.outline-level-6 {
padding-left: 64px;
}
.outline-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outline-badge {
font-size: 10px;
opacity: 0.5;
margin-left: 8px;
flex-shrink: 0;
}
.outline-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--text-muted, #9ca3af);
text-align: center;
}
.outline-empty p {
margin: 4px 0;
}
.outline-hint {
font-family: monospace;
font-size: 12px;
opacity: 0.7;
}
.outline-footer {
padding: 8px 12px;
border-top: 1px solid var(--border-color, #e5e7eb);
font-size: 11px;
color: var(--text-muted, #9ca3af);
}
/* Outline dark mode */
body[class*="dark"] .outline-item { color: #9ca3af; }
body[class*="dark"] .outline-item:hover { background: #374151; color: #e5e7eb; }
body[class*="dark"] .outline-item.active { color: #8b9aff; background: rgba(139, 154, 255, 0.1); border-left-color: #8b9aff; }
body[class*="dark"] .outline-empty { color: #6b7280; }
body[class*="dark"] .outline-footer { border-color: #333; color: #6b7280; }
body[class*='dark'] .outline-item {
color: #9ca3af;
}
body[class*='dark'] .outline-item:hover {
background: #374151;
color: #e5e7eb;
}
body[class*='dark'] .outline-item.active {
color: #8b9aff;
background: rgba(139, 154, 255, 0.1);
border-left-color: #8b9aff;
}
body[class*='dark'] .outline-empty {
color: #6b7280;
}
body[class*='dark'] .outline-footer {
border-color: #333;
color: #6b7280;
}
+121 -24
View File
@@ -1,24 +1,121 @@
.welcome-container { padding: 40px; max-width: 900px; margin: 0 auto; }
.welcome-hero { text-align: center; margin-bottom: 40px; }
.welcome-title { font-size: 32px; font-weight: 700; color: var(--gray-800, #1f2937); }
.welcome-version { font-size: 14px; color: var(--primary-dark, #5661b3); margin-top: 4px; font-weight: 500; }
.welcome-subtitle { font-size: 16px; color: var(--gray-500, #6b7280); margin-top: 8px; }
.welcome-grid { display: grid; gap: 32px; }
.welcome-section h2 { font-size: 18px; margin-bottom: 16px; color: var(--gray-700, #374151); }
.welcome-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.welcome-card { padding: 20px; border: 1px solid var(--gray-200, #e5e7eb); border-radius: 12px; cursor: pointer; text-align: center; transition: all 0.2s; }
.welcome-card:hover { border-color: var(--primary-dark, #5661b3); box-shadow: 0 4px 12px rgba(0,0,0,0.08); transform: translateY(-2px); }
.welcome-card-icon { font-size: 28px; margin-bottom: 8px; }
.welcome-card h3 { font-size: 15px; margin-bottom: 4px; }
.welcome-card p { font-size: 13px; color: var(--gray-500); }
.welcome-card kbd { display: inline-block; margin-top: 8px; padding: 2px 8px; background: var(--gray-100); border: 1px solid var(--gray-300); border-radius: 4px; font-size: 12px; font-family: 'JetBrains Mono', monospace; }
.welcome-features { list-style: none; padding: 0; }
.welcome-features li { padding: 6px 0; font-size: 14px; border-bottom: 1px solid var(--gray-100, #f3f4f6); }
.welcome-features strong { color: var(--primary-dark, #5661b3); }
.welcome-recent-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; }
.welcome-recent-item:hover { background: var(--gray-100); }
.welcome-recent-name { font-size: 14px; font-weight: 500; display: block; }
.welcome-recent-path { font-size: 12px; color: var(--gray-500); display: block; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; }
.welcome-muted { color: var(--gray-400); font-size: 14px; }
.welcome-footer { margin-top: 32px; text-align: center; }
.welcome-checkbox { font-size: 13px; color: var(--gray-500); cursor: pointer; }
.welcome-container {
padding: 40px;
max-width: 900px;
margin: 0 auto;
}
.welcome-hero {
text-align: center;
margin-bottom: 40px;
}
.welcome-title {
font-size: 32px;
font-weight: 700;
color: var(--gray-800, #1f2937);
}
.welcome-version {
font-size: 14px;
color: var(--primary-dark, #5661b3);
margin-top: 4px;
font-weight: 500;
}
.welcome-subtitle {
font-size: 16px;
color: var(--gray-500, #6b7280);
margin-top: 8px;
}
.welcome-grid {
display: grid;
gap: 32px;
}
.welcome-section h2 {
font-size: 18px;
margin-bottom: 16px;
color: var(--gray-700, #374151);
}
.welcome-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.welcome-card {
padding: 20px;
border: 1px solid var(--gray-200, #e5e7eb);
border-radius: 12px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.welcome-card:hover {
border-color: var(--primary-dark, #5661b3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.welcome-card-icon {
font-size: 28px;
margin-bottom: 8px;
}
.welcome-card h3 {
font-size: 15px;
margin-bottom: 4px;
}
.welcome-card p {
font-size: 13px;
color: var(--gray-500);
}
.welcome-card kbd {
display: inline-block;
margin-top: 8px;
padding: 2px 8px;
background: var(--gray-100);
border: 1px solid var(--gray-300);
border-radius: 4px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
}
.welcome-features {
list-style: none;
padding: 0;
}
.welcome-features li {
padding: 6px 0;
font-size: 14px;
border-bottom: 1px solid var(--gray-100, #f3f4f6);
}
.welcome-features strong {
color: var(--primary-dark, #5661b3);
}
.welcome-recent-item {
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
}
.welcome-recent-item:hover {
background: var(--gray-100);
}
.welcome-recent-name {
font-size: 14px;
font-weight: 500;
display: block;
}
.welcome-recent-path {
font-size: 12px;
color: var(--gray-500);
display: block;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
}
.welcome-muted {
color: var(--gray-400);
font-size: 14px;
}
.welcome-footer {
margin-top: 32px;
text-align: center;
}
.welcome-checkbox {
font-size: 13px;
color: var(--gray-500);
cursor: pointer;
}
+48 -48
View File
@@ -7,96 +7,96 @@ body.zen-mode .toolbar,
body.zen-mode .status-bar,
body.zen-mode .sidebar,
body.zen-mode .breadcrumb-bar {
display: none !important;
display: none !important;
}
/* Main content fills viewport */
body.zen-mode .main-content {
height: 100vh;
overflow: hidden;
height: 100vh;
overflow: hidden;
}
/* Editor container takes full width */
body.zen-mode .editor-container {
width: 100% !important;
height: 100% !important;
width: 100% !important;
height: 100% !important;
}
/* Hide preview panes */
body.zen-mode .preview-container,
body.zen-mode .preview-pane,
body.zen-mode .pane-resizer {
display: none !important;
display: none !important;
}
/* Center and style editor content for comfortable reading width */
body.zen-mode .cm-content {
max-width: 700px;
margin: 0 auto;
font-size: 18px;
line-height: 1.8;
padding-top: 80px;
max-width: 700px;
margin: 0 auto;
font-size: 18px;
line-height: 1.8;
padding-top: 80px;
}
body.zen-mode .cm-line {
padding: 4px 0;
padding: 4px 0;
}
body.zen-mode .editor-wrapper {
display: flex;
justify-content: center;
display: flex;
justify-content: center;
}
/* Floating HUD pill */
.zen-hud {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: rgba(255, 255, 255, 0.8);
padding: 10px 24px;
border-radius: 24px;
font-size: 13px;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
z-index: 9999;
pointer-events: none;
display: flex;
align-items: center;
gap: 16px;
transition: opacity 0.3s ease;
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: rgba(255, 255, 255, 0.8);
padding: 10px 24px;
border-radius: 24px;
font-size: 13px;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
z-index: 9999;
pointer-events: none;
display: flex;
align-items: center;
gap: 16px;
transition: opacity 0.3s ease;
}
.zen-hud-stat {
opacity: 0.7;
opacity: 0.7;
}
.zen-hud-sep {
opacity: 0.3;
margin: 0 4px;
opacity: 0.3;
margin: 0 4px;
}
/* Word goal progress bar */
.zen-progress-container {
width: 120px;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
display: none;
width: 120px;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
display: none;
}
.zen-progress-bar {
height: 100%;
background: rgba(255, 178, 70, 0.85);
border-radius: 2px;
transition: width 0.3s ease;
height: 100%;
background: rgba(255, 178, 70, 0.85);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Dark mode — HUD stays dark in both themes */
body[class*="dark"].zen-mode .zen-hud {
background: rgba(0, 0, 0, 0.7);
body[class*='dark'].zen-mode .zen-hud {
background: rgba(0, 0, 0, 0.7);
}
+1998 -1813
View File
File diff suppressed because it is too large Load Diff
+128 -124
View File
@@ -9,13 +9,13 @@
* ============================================ */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 0;
cursor: pointer;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 0;
cursor: pointer;
}
/* ============================================
@@ -23,22 +23,23 @@
* ============================================ */
.modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: calc(var(--z-modal, 200) + 1);
opacity: 0;
visibility: hidden;
transition: opacity var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)),
visibility var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
padding: var(--spacing-4, 1rem);
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: calc(var(--z-modal, 200) + 1);
opacity: 0;
visibility: hidden;
transition:
opacity var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)),
visibility var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
padding: var(--spacing-4, 1rem);
}
.modal.open {
opacity: 1;
visibility: visible;
opacity: 1;
visibility: visible;
}
/* ============================================
@@ -46,23 +47,23 @@
* ============================================ */
.modal-content {
position: relative;
z-index: 1;
width: 100%;
background: hsl(var(--background, 0 0% 100%));
border-radius: var(--radius-lg, 0.5rem);
box-shadow: var(--shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1));
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
transform: scale(0.95);
transition: transform var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
position: relative;
z-index: 1;
width: 100%;
background: hsl(var(--background, 0 0% 100%));
border-radius: var(--radius-lg, 0.5rem);
box-shadow: var(--shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1));
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
transform: scale(0.95);
transition: transform var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
}
.modal.open .modal-content {
transform: scale(1);
transform: scale(1);
}
/* ============================================
@@ -70,19 +71,19 @@
* ============================================ */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
background: hsl(var(--muted, 210 40% 96.1%));
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
background: hsl(var(--muted, 210 40% 96.1%));
}
.modal-header h3 {
margin: 0;
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-semibold, 600);
color: hsl(var(--foreground, 222.2 84% 4.9%));
margin: 0;
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-semibold, 600);
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
/* ============================================
@@ -90,31 +91,32 @@
* ============================================ */
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--radius, 0.5rem);
background: transparent;
color: hsl(var(--muted-foreground, 215.4 16.3% 46.9%));
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: background-color var(--transition-fast, 150ms),
color var(--transition-fast, 150ms);
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--radius, 0.5rem);
background: transparent;
color: hsl(var(--muted-foreground, 215.4 16.3% 46.9%));
font-size: 24px;
line-height: 1;
cursor: pointer;
transition:
background-color var(--transition-fast, 150ms),
color var(--transition-fast, 150ms);
}
.modal-close:hover {
background: hsl(var(--accent, 210 40% 96.1%));
color: hsl(var(--foreground, 222.2 84% 4.9%));
background: hsl(var(--accent, 210 40% 96.1%));
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
.modal-close:focus-visible {
outline: 2px solid hsl(var(--ring, 227 44% 52%));
outline-offset: 2px;
outline: 2px solid hsl(var(--ring, 227 44% 52%));
outline-offset: 2px;
}
/* ============================================
@@ -122,9 +124,9 @@
* ============================================ */
.modal-body {
padding: var(--spacing-6, 1.5rem);
overflow-y: auto;
flex: 1;
padding: var(--spacing-6, 1.5rem);
overflow-y: auto;
flex: 1;
}
/* ============================================
@@ -132,19 +134,19 @@
* ============================================ */
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-3, 0.75rem);
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
border-top: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
background: hsl(var(--muted, 210 40% 96.1%));
display: flex;
justify-content: flex-end;
gap: var(--spacing-3, 0.75rem);
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
border-top: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
background: hsl(var(--muted, 210 40% 96.1%));
}
.modal-footer .btn {
min-width: 80px;
/* styles-modern.css sets flex:1 on .btn-primary/.btn-secondary globally
min-width: 80px;
/* styles-modern.css sets flex:1 on .btn-primary/.btn-secondary globally
* which causes footer buttons to stretch to full width. Override here. */
flex: none;
flex: none;
}
/* ============================================
@@ -152,60 +154,60 @@
* ============================================ */
.modal-body .form-row {
display: flex;
align-items: center;
gap: var(--spacing-3, 0.75rem);
margin-bottom: var(--spacing-3, 0.75rem);
display: flex;
align-items: center;
gap: var(--spacing-3, 0.75rem);
margin-bottom: var(--spacing-3, 0.75rem);
}
.modal-body .form-row label {
min-width: 100px;
font-size: var(--text-sm, 0.875rem);
color: hsl(var(--foreground, 222.2 84% 4.9%));
min-width: 100px;
font-size: var(--text-sm, 0.875rem);
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
.modal-body .form-row input,
.modal-body .form-row select {
flex: 1;
padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
border: 1px solid hsl(var(--input, 214.3 31.8% 91.4%));
border-radius: var(--radius, 0.5rem);
font-size: var(--text-sm, 0.875rem);
background: hsl(var(--background, 0 0% 100%));
color: hsl(var(--foreground, 222.2 84% 4.9%));
flex: 1;
padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
border: 1px solid hsl(var(--input, 214.3 31.8% 91.4%));
border-radius: var(--radius, 0.5rem);
font-size: var(--text-sm, 0.875rem);
background: hsl(var(--background, 0 0% 100%));
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
.modal-body .form-row input:focus,
.modal-body .form-row select:focus {
outline: 2px solid hsl(var(--ring, 227 44% 52%));
outline-offset: 1px;
outline: 2px solid hsl(var(--ring, 227 44% 52%));
outline-offset: 1px;
}
.modal-body .export-section {
margin-bottom: var(--spacing-4, 1rem);
padding-bottom: var(--spacing-4, 1rem);
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
margin-bottom: var(--spacing-4, 1rem);
padding-bottom: var(--spacing-4, 1rem);
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
}
.modal-body .export-section:last-child {
border-bottom: none;
margin-bottom: 0;
border-bottom: none;
margin-bottom: 0;
}
.modal-body .export-section label {
display: block;
font-weight: var(--font-medium, 500);
margin-bottom: var(--spacing-2, 0.5rem);
color: hsl(var(--foreground, 222.2 84% 4.9%));
display: block;
font-weight: var(--font-medium, 500);
margin-bottom: var(--spacing-2, 0.5rem);
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
.modal-body .checkbox-group label {
display: flex;
align-items: center;
gap: var(--spacing-2, 0.5rem);
margin-bottom: var(--spacing-2, 0.5rem);
font-weight: var(--font-normal, 400);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--spacing-2, 0.5rem);
margin-bottom: var(--spacing-2, 0.5rem);
font-weight: var(--font-normal, 400);
cursor: pointer;
}
/* ============================================
@@ -213,19 +215,19 @@
* ============================================ */
.modal-content.small {
max-width: 400px;
min-width: 320px;
max-width: 400px;
min-width: 320px;
}
.modal-content.large {
max-width: 800px;
min-width: 560px;
max-width: 800px;
min-width: 560px;
}
.modal-content.full {
max-width: 95vw;
max-height: 95vh;
min-width: min(95vw, 700px);
max-width: 95vw;
max-height: 95vh;
min-width: min(95vw, 700px);
}
/* ============================================
@@ -233,16 +235,18 @@
* ============================================ */
.dark .modal-content,
[data-theme="dark"] .modal-content {
background: hsl(var(--background));
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.4), 0 8px 10px -6px rgb(0 0 0 / 0.3);
[data-theme='dark'] .modal-content {
background: hsl(var(--background));
box-shadow:
0 20px 25px -5px rgb(0 0 0 / 0.4),
0 8px 10px -6px rgb(0 0 0 / 0.3);
}
.dark .modal-header,
.dark .modal-footer,
[data-theme="dark"] .modal-header,
[data-theme="dark"] .modal-footer {
background: hsl(var(--muted));
[data-theme='dark'] .modal-header,
[data-theme='dark'] .modal-footer {
background: hsl(var(--muted));
}
/* ============================================
@@ -250,10 +254,10 @@
* ============================================ */
@media (prefers-reduced-motion: reduce) {
.modal,
.modal-content {
transition: none;
}
.modal,
.modal-content {
transition: none;
}
}
/* ============================================
@@ -261,5 +265,5 @@
* ============================================ */
.modal.hidden {
display: none;
display: none;
}
+49 -17
View File
@@ -148,7 +148,7 @@
* ============================================ */
.dark,
[data-theme="dark"] {
[data-theme='dark'] {
/* Background and foreground */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
@@ -205,24 +205,56 @@
* Semantic Color Classes
* ============================================ */
.bg-background { background-color: hsl(var(--background)); }
.bg-foreground { background-color: hsl(var(--foreground)); }
.bg-card { background-color: hsl(var(--card)); }
.bg-primary { background-color: hsl(var(--primary)); }
.bg-secondary { background-color: hsl(var(--secondary)); }
.bg-muted { background-color: hsl(var(--muted)); }
.bg-accent { background-color: hsl(var(--accent)); }
.bg-destructive { background-color: hsl(var(--destructive)); }
.bg-background {
background-color: hsl(var(--background));
}
.bg-foreground {
background-color: hsl(var(--foreground));
}
.bg-card {
background-color: hsl(var(--card));
}
.bg-primary {
background-color: hsl(var(--primary));
}
.bg-secondary {
background-color: hsl(var(--secondary));
}
.bg-muted {
background-color: hsl(var(--muted));
}
.bg-accent {
background-color: hsl(var(--accent));
}
.bg-destructive {
background-color: hsl(var(--destructive));
}
.text-foreground { color: hsl(var(--foreground)); }
.text-primary { color: hsl(var(--primary)); }
.text-secondary { color: hsl(var(--secondary-foreground)); }
.text-muted-foreground { color: hsl(var(--muted-foreground)); }
.text-destructive { color: hsl(var(--destructive)); }
.text-foreground {
color: hsl(var(--foreground));
}
.text-primary {
color: hsl(var(--primary));
}
.text-secondary {
color: hsl(var(--secondary-foreground));
}
.text-muted-foreground {
color: hsl(var(--muted-foreground));
}
.text-destructive {
color: hsl(var(--destructive));
}
.border-border { border-color: hsl(var(--border)); }
.border-primary { border-color: hsl(var(--primary)); }
.border-input { border-color: hsl(var(--input)); }
.border-border {
border-color: hsl(var(--border));
}
.border-primary {
border-color: hsl(var(--primary));
}
.border-input {
border-color: hsl(var(--input));
}
/* ============================================
* Utility Classes
+491 -470
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -22,10 +22,10 @@ GET /resources
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| page | integer | No | Page number (default: 1) |
| limit | integer | No | Items per page (default: 20) |
| Parameter | Type | Required | Description |
| --------- | ------- | -------- | ---------------------------- |
| page | integer | No | Page number (default: 1) |
| limit | integer | No | Items per page (default: 20) |
**Response:**
@@ -82,9 +82,9 @@ DELETE /resources/:id
## Error Codes
| Code | Description |
|------|-------------|
| 400 | Bad Request |
| 401 | Unauthorized |
| 404 | Not Found |
| 500 | Internal Server Error |
| Code | Description |
| ---- | --------------------- |
| 400 | Bad Request |
| 401 | Unauthorized |
| 404 | Not Found |
| 500 | Internal Server Error |
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Blog Post Title
date: {{DATE}}
date: { { DATE } }
author: Your Name
tags: []
---
+5
View File
@@ -3,18 +3,23 @@
## [Unreleased]
### Added
- New feature
### Changed
- Updated feature
### Fixed
- Bug fix
### Removed
- Removed feature
## [1.0.0] - {{DATE}}
### Added
- Initial release
+20 -16
View File
@@ -9,50 +9,54 @@ Brief description of what is being compared and why.
## Feature Comparison
| Feature | Option A | Option B |
|---------|----------|----------|
| Feature 1 | Yes | Yes |
| Feature 2 | Yes | No |
| Feature 3 | No | Yes |
| Feature 4 | Partial | Yes |
| Feature | Option A | Option B |
| --------- | -------- | -------- |
| Feature 1 | Yes | Yes |
| Feature 2 | Yes | No |
| Feature 3 | No | Yes |
| Feature 4 | Partial | Yes |
## Pricing
| Plan | Option A | Option B |
|------|----------|----------|
| Free | Limited | Limited |
| Pro | $10/mo | $15/mo |
| Enterprise | Custom | Custom |
| Plan | Option A | Option B |
| ---------- | -------- | -------- |
| Free | Limited | Limited |
| Pro | $10/mo | $15/mo |
| Enterprise | Custom | Custom |
## Pros and Cons
### Option A
**Pros:**
- Pro 1
- Pro 2
**Cons:**
- Con 1
- Con 2
### Option B
**Pros:**
- Pro 1
- Pro 2
**Cons:**
- Con 1
- Con 2
## Performance
| Metric | Option A | Option B |
|--------|----------|----------|
| Speed | Fast | Moderate |
| Memory | Low | Medium |
| Scalability | Good | Excellent |
| Metric | Option A | Option B |
| ----------- | -------- | --------- |
| Speed | Fast | Moderate |
| Memory | Low | Medium |
| Scalability | Good | Excellent |
## Recommendation
+1
View File
@@ -2,6 +2,7 @@
**Date:** {{DATE}}
**Attendees:**
- Name 1
- Name 2
+17 -12
View File
@@ -12,43 +12,48 @@
## Scope
### In Scope
- Item 1
- Item 2
### Out of Scope
- Item 1
## Milestones
| Milestone | Target Date | Status |
|-----------|-------------|--------|
| Kickoff | {{DATE}} | Not Started |
| MVP | TBD | Not Started |
| Launch | TBD | Not Started |
| Milestone | Target Date | Status |
| --------- | ----------- | ----------- |
| Kickoff | {{DATE}} | Not Started |
| MVP | TBD | Not Started |
| Launch | TBD | Not Started |
## Resources
| Role | Person | Allocation |
|------|--------|------------|
| Lead | Name | 100% |
| Developer | Name | 50% |
| Role | Person | Allocation |
| --------- | ------ | ---------- |
| Lead | Name | 100% |
| Developer | Name | 50% |
## Risks
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Risk 1 | High | Medium | Plan |
| Risk | Impact | Likelihood | Mitigation |
| ------ | ------ | ---------- | ---------- |
| Risk 1 | High | Medium | Plan |
## Timeline
### Phase 1: Planning
- [ ] Define requirements
- [ ] Create design documents
### Phase 2: Development
- [ ] Implement core features
- [ ] Write tests
### Phase 3: Launch
- [ ] Deploy to production
- [ ] Monitor and iterate
+3 -3
View File
@@ -21,9 +21,9 @@ const project = require('project-name');
## Configuration
| Option | Default | Description |
|--------|---------|-------------|
| option1 | true | Description |
| Option | Default | Description |
| ------- | ------- | ----------- |
| option1 | true | Description |
## Contributing
+8 -6
View File
@@ -12,10 +12,12 @@ Brief description of what this feature does.
## Requirements
### Functional Requirements
1. Requirement 1
2. Requirement 2
### Non-Functional Requirements
1. Performance requirement
2. Security requirement
@@ -27,9 +29,9 @@ Describe the technical approach.
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | /api/resource | Get resource |
| Method | Path | Description |
| ------ | ------------- | ------------ |
| GET | /api/resource | Get resource |
## Testing Strategy
@@ -37,6 +39,6 @@ Describe how this will be tested.
## Timeline
| Phase | Duration | Deliverables |
|-------|----------|-------------|
| Phase 1 | 1 week | Core implementation |
| Phase | Duration | Deliverables |
| ------- | -------- | ------------------- |
| Phase 1 | 1 week | Core implementation |
+3 -1
View File
@@ -42,7 +42,7 @@ Walk through the implementation.
```javascript
// Example code
function example() {
return 'Hello';
return 'Hello';
}
```
@@ -53,10 +53,12 @@ How to verify everything works.
## Common Issues
### Issue 1
**Problem:** Description of the problem.
**Solution:** How to fix it.
### Issue 2
**Problem:** Description of the problem.
**Solution:** How to fix it.
+204 -203
View File
@@ -1,245 +1,246 @@
/**
* ModalManager - Unified modal system with accessibility support
* @version 4.4.3
* @version 4.4.4
*/
class ModalManager {
#modal;
#backdrop;
#options;
#lastFocusedElement;
#focusableElements;
#eventListeners;
#isOpen;
#modal;
#backdrop;
#options;
#lastFocusedElement;
#focusableElements;
#eventListeners;
#isOpen;
static #openModals = [];
static #openModals = [];
constructor(element, options = {}) {
this.#modal = typeof element === 'string' ? document.querySelector(element) : element;
this.#options = {
closeOnBackdrop: true,
closeOnEscape: true,
focusFirst: true,
onOpen: null,
onClose: null,
...options
};
this.#isOpen = false;
this.#eventListeners = [];
this.#init();
constructor(element, options = {}) {
this.#modal = typeof element === 'string' ? document.querySelector(element) : element;
this.#options = {
closeOnBackdrop: true,
closeOnEscape: true,
focusFirst: true,
onOpen: null,
onClose: null,
...options,
};
this.#isOpen = false;
this.#eventListeners = [];
this.#init();
}
#init() {
// Ensure modal has required attributes
if (!this.#modal.hasAttribute('role')) {
this.#modal.setAttribute('role', 'dialog');
}
if (!this.#modal.hasAttribute('aria-modal')) {
this.#modal.setAttribute('aria-modal', 'true');
}
#init() {
// Ensure modal has required attributes
if (!this.#modal.hasAttribute('role')) {
this.#modal.setAttribute('role', 'dialog');
}
if (!this.#modal.hasAttribute('aria-modal')) {
this.#modal.setAttribute('aria-modal', 'true');
}
// Find or create backdrop
this.#backdrop = this.#modal.querySelector('.modal-backdrop');
// Find or create backdrop
this.#backdrop = this.#modal.querySelector('.modal-backdrop');
// Setup close triggers
this.#setupCloseTriggers();
}
// Setup close triggers
this.#setupCloseTriggers();
#setupCloseTriggers() {
// Close button
const closeBtn = this.#modal.querySelector('.modal-close');
if (closeBtn) {
const handler = (e) => {
e.preventDefault();
this.close();
};
closeBtn.addEventListener('click', handler);
this.#eventListeners.push({ el: closeBtn, type: 'click', handler });
}
#setupCloseTriggers() {
// Close button
const closeBtn = this.#modal.querySelector('.modal-close');
if (closeBtn) {
const handler = (e) => {
e.preventDefault();
this.close();
};
closeBtn.addEventListener('click', handler);
this.#eventListeners.push({ el: closeBtn, type: 'click', handler });
}
// Elements with data-close attribute
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
closeTriggers.forEach((el) => {
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
return;
}
const handler = (e) => {
e.preventDefault();
this.close();
};
el.addEventListener('click', handler);
this.#eventListeners.push({ el, type: 'click', handler });
});
}
// Elements with data-close attribute
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
closeTriggers.forEach(el => {
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
return;
}
const handler = (e) => {
e.preventDefault();
this.close();
};
el.addEventListener('click', handler);
this.#eventListeners.push({ el, type: 'click', handler });
});
#getFocusableElements() {
const selector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
return Array.from(this.#modal.querySelectorAll(selector)).filter(
(el) => el.offsetParent !== null && !el.classList.contains('modal-backdrop')
);
}
#trapFocus(e) {
if (e.key !== 'Tab') return;
const focusable = this.#getFocusableElements();
if (focusable.length === 0) return;
const firstEl = focusable[0];
const lastEl = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
}
#getFocusableElements() {
const selector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
return Array.from(this.#modal.querySelectorAll(selector))
.filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop'));
#handleKeydown(e) {
if (e.key === 'Escape' && this.#options.closeOnEscape) {
e.preventDefault();
this.close();
}
this.#trapFocus(e);
}
#trapFocus(e) {
if (e.key !== 'Tab') return;
open() {
if (this.#isOpen) return;
// Store last focused element
this.#lastFocusedElement = document.activeElement;
// Track open modals
ModalManager.#openModals.push(this);
// Show modal: remove hidden first, force a reflow so the browser
// records opacity:0 as the start state, then add 'open' to trigger
// the CSS transition. Without the reflow, both class changes are
// batched into one style recalculation and the transition is skipped.
this.#modal.classList.remove('hidden');
void this.#modal.offsetHeight; // Force reflow — do not remove
this.#modal.classList.add('open');
this.#isOpen = true;
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Add keyboard listener
const keydownHandler = (e) => this.#handleKeydown(e);
document.addEventListener('keydown', keydownHandler);
this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler });
// Focus first element
if (this.#options.focusFirst) {
requestAnimationFrame(() => {
const focusable = this.#getFocusableElements();
if (focusable.length === 0) return;
const firstEl = focusable[0];
const lastEl = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
if (focusable.length > 0) {
focusable[0].focus();
}
});
}
#handleKeydown(e) {
if (e.key === 'Escape' && this.#options.closeOnEscape) {
e.preventDefault();
this.close();
}
this.#trapFocus(e);
// Callback
if (this.#options.onOpen) {
this.#options.onOpen(this);
}
open() {
if (this.#isOpen) return;
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:open'));
}
// Store last focused element
this.#lastFocusedElement = document.activeElement;
close() {
if (!this.#isOpen) return;
// Track open modals
ModalManager.#openModals.push(this);
// Show modal: remove hidden first, force a reflow so the browser
// records opacity:0 as the start state, then add 'open' to trigger
// the CSS transition. Without the reflow, both class changes are
// batched into one style recalculation and the transition is skipped.
this.#modal.classList.remove('hidden');
void this.#modal.offsetHeight; // Force reflow — do not remove
this.#modal.classList.add('open');
this.#isOpen = true;
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Add keyboard listener
const keydownHandler = (e) => this.#handleKeydown(e);
document.addEventListener('keydown', keydownHandler);
this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler });
// Focus first element
if (this.#options.focusFirst) {
requestAnimationFrame(() => {
const focusable = this.#getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
}
});
}
// Callback
if (this.#options.onOpen) {
this.#options.onOpen(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:open'));
// Remove from open modals
const index = ModalManager.#openModals.indexOf(this);
if (index > -1) {
ModalManager.#openModals.splice(index, 1);
}
close() {
if (!this.#isOpen) return;
// Start hide transition
this.#modal.classList.remove('open');
this.#isOpen = false;
// Remove from open modals
const index = ModalManager.#openModals.indexOf(this);
if (index > -1) {
ModalManager.#openModals.splice(index, 1);
}
// Re-add 'hidden' after the CSS transition completes so the modal
// is fully removed from rendering (display:none), not just invisible.
// We use both transitionend and a setTimeout fallback because
// transitionend never fires when prefers-reduced-motion disables transitions.
let hidden = false;
const addHidden = () => {
if (hidden || this.#isOpen) return;
hidden = true;
this.#modal.removeEventListener('transitionend', onTransitionEnd);
this.#modal.classList.add('hidden');
};
const onTransitionEnd = (e) => {
if (e.target !== this.#modal) return;
addHidden();
};
this.#modal.addEventListener('transitionend', onTransitionEnd);
setTimeout(addHidden, 250); // fallback: slightly longer than 200ms transition
// Start hide transition
this.#modal.classList.remove('open');
this.#isOpen = false;
// Re-add 'hidden' after the CSS transition completes so the modal
// is fully removed from rendering (display:none), not just invisible.
// We use both transitionend and a setTimeout fallback because
// transitionend never fires when prefers-reduced-motion disables transitions.
let hidden = false;
const addHidden = () => {
if (hidden || this.#isOpen) return;
hidden = true;
this.#modal.removeEventListener('transitionend', onTransitionEnd);
this.#modal.classList.add('hidden');
};
const onTransitionEnd = (e) => {
if (e.target !== this.#modal) return;
addHidden();
};
this.#modal.addEventListener('transitionend', onTransitionEnd);
setTimeout(addHidden, 250); // fallback: slightly longer than 200ms transition
// Restore body scroll if no modals open
if (ModalManager.#openModals.length === 0) {
document.body.style.overflow = '';
}
// Remove keyboard listener
const keydownListener = this.#eventListeners.find(
l => l.el === document && l.type === 'keydown'
);
if (keydownListener) {
document.removeEventListener('keydown', keydownListener.handler);
this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener);
}
// Restore focus
if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') {
this.#lastFocusedElement.focus();
}
// Callback
if (this.#options.onClose) {
this.#options.onClose(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:close'));
// Restore body scroll if no modals open
if (ModalManager.#openModals.length === 0) {
document.body.style.overflow = '';
}
isOpen() {
return this.#isOpen;
// Remove keyboard listener
const keydownListener = this.#eventListeners.find(
(l) => l.el === document && l.type === 'keydown'
);
if (keydownListener) {
document.removeEventListener('keydown', keydownListener.handler);
this.#eventListeners = this.#eventListeners.filter((l) => l !== keydownListener);
}
destroy() {
// Close if open
if (this.#isOpen) {
this.close();
}
// Remove all event listeners
this.#eventListeners.forEach(({ el, type, handler }) => {
el.removeEventListener(type, handler);
});
this.#eventListeners = [];
// Restore focus
if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') {
this.#lastFocusedElement.focus();
}
// Callback
if (this.#options.onClose) {
this.#options.onClose(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:close'));
}
isOpen() {
return this.#isOpen;
}
destroy() {
// Close if open
if (this.#isOpen) {
this.close();
}
// Remove all event listeners
this.#eventListeners.forEach(({ el, type, handler }) => {
el.removeEventListener(type, handler);
});
this.#eventListeners = [];
}
}
// Export for use in renderer - avoid duplicate declaration
if (typeof window !== 'undefined' && !window.ModalManager) {
window.ModalManager = ModalManager;
window.ModalManager = ModalManager;
}
// CommonJS export
+9 -7
View File
@@ -1,12 +1,14 @@
function createWelcomeContent(recentFiles = [], appVersion = '') {
const recentHtml = recentFiles.length
? recentFiles.map(f => {
const name = f.split(/[/\\]/).pop();
return `<div class="welcome-recent-item" data-path="${f}"><span class="welcome-recent-name">${name}</span><span class="welcome-recent-path">${f}</span></div>`;
}).join('')
: '<p class="welcome-muted">No recent files</p>';
const recentHtml = recentFiles.length
? recentFiles
.map((f) => {
const name = f.split(/[/\\]/).pop();
return `<div class="welcome-recent-item" data-path="${f}"><span class="welcome-recent-name">${name}</span><span class="welcome-recent-path">${f}</span></div>`;
})
.join('')
: '<p class="welcome-muted">No recent files</p>';
return `
return `
<div class="welcome-container">
<div class="welcome-hero">
<h1 class="welcome-title">MarkdownConverter</h1>
File diff suppressed because it is too large Load Diff
+217 -225
View File
@@ -4,172 +4,154 @@
* Toggle with F11, exit with Escape.
*/
const {
EditorView,
ViewPlugin,
Decoration,
DecorationSet,
} = require('@codemirror/view');
const { EditorView, ViewPlugin, Decoration } = require('@codemirror/view');
const { RangeSetBuilder } = require('@codemirror/state');
class ZenMode {
/**
* @param {import('./renderer').TabManager} tabManager
*/
constructor(tabManager) {
this.tabManager = tabManager;
this.active = false;
this._hud = null;
this._timerInterval = null;
this._sessionStart = null;
this._extensions = [];
/**
* @param {import('./renderer').TabManager} tabManager
*/
constructor(tabManager) {
this.tabManager = tabManager;
this.active = false;
this._hud = null;
this._timerInterval = null;
this._sessionStart = null;
this._extensions = [];
}
activate() {
if (this.active) return;
this.active = true;
document.body.classList.add('zen-mode');
// Collapse sidebar
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.classList.add('collapsed');
// Hide preview pane for the active tab
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
if (tab) {
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
if (previewContainer) previewContainer.classList.add('hidden');
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
if (editorPane) editorPane.classList.add('full-width');
// Add CM6 extensions to the active editor
if (tab.editorView) {
this._extensions = [typewriterScrollExtension, lineDimmingExtension];
// Reconfigure is not straightforward with CM6's immutable state.
// Instead we dispatch effects or simply rebuild with new extensions.
// Since we cannot hot-swap extensions on an existing EditorView,
// we store a reference and the CSS + updateListener handle the rest.
// The typewriter + dimming plugins are applied via a compartment approach
// would be ideal, but for simplicity we use DOM + updateListener.
this._applyTypewriterBehavior(tab.editorView);
}
}
activate() {
if (this.active) return;
this.active = true;
// Create HUD
this._createHUD();
document.body.classList.add('zen-mode');
// Start session timer
this._sessionStart = Date.now();
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
// Collapse sidebar
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.classList.add('collapsed');
// Focus editor
if (tab?.editorView) tab.editorView.focus();
}
// Hide preview pane for the active tab
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
if (tab) {
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
if (previewContainer) previewContainer.classList.add('hidden');
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
if (editorPane) editorPane.classList.add('full-width');
deactivate() {
if (!this.active) return;
this.active = false;
// Add CM6 extensions to the active editor
if (tab.editorView) {
this._extensions = [
typewriterScrollExtension,
lineDimmingExtension,
];
// Reconfigure is not straightforward with CM6's immutable state.
// Instead we dispatch effects or simply rebuild with new extensions.
// Since we cannot hot-swap extensions on an existing EditorView,
// we store a reference and the CSS + updateListener handle the rest.
// The typewriter + dimming plugins are applied via a compartment approach
// would be ideal, but for simplicity we use DOM + updateListener.
this._applyTypewriterBehavior(tab.editorView);
}
}
document.body.classList.remove('zen-mode');
// Create HUD
this._createHUD();
// Restore preview pane for the active tab
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
if (tab) {
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
if (previewContainer) previewContainer.classList.remove('hidden');
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
if (editorPane) editorPane.classList.remove('full-width');
// Start session timer
this._sessionStart = Date.now();
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
// Focus editor
if (tab?.editorView) tab.editorView.focus();
// Remove typewriter listener
if (tab.editorView && this._selectionListener) {
window.removeEventListener('zen-typewriter', this._selectionListener);
this._selectionListener = null;
}
}
deactivate() {
if (!this.active) return;
this.active = false;
document.body.classList.remove('zen-mode');
// Restore preview pane for the active tab
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
if (tab) {
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
if (previewContainer) previewContainer.classList.remove('hidden');
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
if (editorPane) editorPane.classList.remove('full-width');
// Remove typewriter listener
if (tab.editorView && this._selectionListener) {
window.removeEventListener('zen-typewriter', this._selectionListener);
this._selectionListener = null;
}
}
// Remove HUD
if (this._hud && this._hud.parentNode) {
this._hud.parentNode.removeChild(this._hud);
this._hud = null;
}
// Stop timer
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
this._sessionStart = null;
this._extensions = [];
// Remove HUD
if (this._hud && this._hud.parentNode) {
this._hud.parentNode.removeChild(this._hud);
this._hud = null;
}
toggle() {
if (this.active) {
this.deactivate();
} else {
this.activate();
}
// Stop timer
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
this._sessionStart = null;
this._extensions = [];
}
_applyTypewriterBehavior(view) {
// We use a custom update listener that scrolls the cursor to center.
// This is stored on the instance so we can clean it up.
const scrollTimeout = null;
toggle() {
if (this.active) {
this.deactivate();
} else {
this.activate();
}
}
const scrollFn = () => {
if (!this.active) return;
const pos = view.state.selection.main.head;
requestAnimationFrame(() => {
view.dispatch({
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
});
};
// Listen for selection/doc changes via a polling approach
// since we cannot add extensions to an existing view.
// Instead, use the DOM scrollIntoView after selection changes.
const observer = new MutationObserver(() => {
// No-op, we use interval below
_applyTypewriterBehavior(view) {
// We use a custom update listener that scrolls the cursor to center.
const scrollFn = () => {
if (!this.active) return;
const pos = view.state.selection.main.head;
requestAnimationFrame(() => {
view.dispatch({
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
});
};
// Use interval to keep cursor centered during typing
this._scrollInterval = setInterval(() => {
if (!this.active) {
clearInterval(this._scrollInterval);
this._scrollInterval = null;
return;
}
// Typewriter scroll: center the cursor line
try {
const pos = view.state.selection.main.head;
const coords = view.coordsAtPos(pos);
const scroller = view.scrollDOM;
if (coords && scroller) {
const scrollerRect = scroller.getBoundingClientRect();
const cursorCenter = coords.top + (coords.bottom - coords.top) / 2;
const viewCenter = scrollerRect.top + scrollerRect.height / 2;
const diff = cursorCenter - viewCenter;
if (Math.abs(diff) > 30) {
scroller.scrollTop += diff;
}
}
} catch (e) {
// Ignore errors from destroyed views
}
}, 200);
// Use interval to keep cursor centered during typing
this._scrollInterval = setInterval(() => {
if (!this.active) {
clearInterval(this._scrollInterval);
this._scrollInterval = null;
return;
}
// Typewriter scroll: center the cursor line
try {
const pos = view.state.selection.main.head;
const coords = view.coordsAtPos(pos);
const scroller = view.scrollDOM;
if (coords && scroller) {
const scrollerRect = scroller.getBoundingClientRect();
const cursorCenter = coords.top + (coords.bottom - coords.top) / 2;
const viewCenter = scrollerRect.top + scrollerRect.height / 2;
const diff = cursorCenter - viewCenter;
if (Math.abs(diff) > 30) {
scroller.scrollTop += diff;
}
}
} catch {
// Ignore errors from destroyed views
}
}, 200);
// Initial scroll to center
scrollFn();
}
// Initial scroll to center
scrollFn();
}
_createHUD() {
const hud = document.createElement('div');
hud.className = 'zen-hud';
hud.innerHTML = `
_createHUD() {
const hud = document.createElement('div');
hud.className = 'zen-hud';
hud.innerHTML = `
<span class="zen-hud-item" id="zen-words">0 words</span>
<span class="zen-hud-sep">&middot;</span>
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
@@ -179,114 +161,124 @@ class ZenMode {
<div class="zen-progress-bar" id="zen-progress-bar"></div>
</div>
`;
document.body.appendChild(hud);
this._hud = hud;
this._updateHUD();
document.body.appendChild(hud);
this._hud = hud;
this._updateHUD();
}
_updateHUD() {
if (!this.active || !this._hud) return;
const content = this.tabManager.getEditorContent();
const words = content.trim()
? content
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length
: 0;
const readingTime = Math.max(1, Math.ceil(words / 200));
const wordsEl = document.getElementById('zen-words');
const readingEl = document.getElementById('zen-reading-time');
const timerEl = document.getElementById('zen-timer');
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
if (readingEl) readingEl.textContent = `${readingTime} min read`;
// Session timer
if (this._sessionStart) {
const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000);
const minutes = String(Math.floor(elapsed / 60)).padStart(2, '0');
const seconds = String(elapsed % 60).padStart(2, '0');
if (timerEl) timerEl.textContent = `${minutes}:${seconds}`;
}
_updateHUD() {
if (!this.active || !this._hud) return;
const content = this.tabManager.getEditorContent();
const words = content.trim() ? content.trim().split(/\s+/).filter(w => w.length > 0).length : 0;
const readingTime = Math.max(1, Math.ceil(words / 200));
const wordsEl = document.getElementById('zen-words');
const readingEl = document.getElementById('zen-reading-time');
const timerEl = document.getElementById('zen-timer');
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
if (readingEl) readingEl.textContent = `${readingTime} min read`;
// Session timer
if (this._sessionStart) {
const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000);
const minutes = String(Math.floor(elapsed / 60)).padStart(2, '0');
const seconds = String(elapsed % 60).padStart(2, '0');
if (timerEl) timerEl.textContent = `${minutes}:${seconds}`;
}
// Word goal progress
const goalStr = localStorage.getItem('zen-word-goal');
const progressBar = document.getElementById('zen-progress-bar');
const progressContainer = document.getElementById('zen-progress-container');
if (progressBar && progressContainer) {
if (goalStr) {
const goal = parseInt(goalStr, 10);
if (goal > 0) {
const pct = Math.min(100, Math.round((words / goal) * 100));
progressBar.style.width = `${pct}%`;
progressContainer.style.display = 'block';
} else {
progressContainer.style.display = 'none';
}
} else {
progressContainer.style.display = 'none';
}
// Word goal progress
const goalStr = localStorage.getItem('zen-word-goal');
const progressBar = document.getElementById('zen-progress-bar');
const progressContainer = document.getElementById('zen-progress-container');
if (progressBar && progressContainer) {
if (goalStr) {
const goal = parseInt(goalStr, 10);
if (goal > 0) {
const pct = Math.min(100, Math.round((words / goal) * 100));
progressBar.style.width = `${pct}%`;
progressContainer.style.display = 'block';
} else {
progressContainer.style.display = 'none';
}
} else {
progressContainer.style.display = 'none';
}
}
}
}
// Typewriter scroll ViewPlugin
const typewriterScrollExtension = ViewPlugin.fromClass(class {
const typewriterScrollExtension = ViewPlugin.fromClass(
class {
constructor(view) {
this.view = view;
this.view = view;
}
update(update) {
if (update.selectionSet || update.docChanged) {
const pos = update.state.selection.main.head;
requestAnimationFrame(() => {
if (this.view.dom && this.view.dom.isConnected) {
this.view.dispatch({
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
}
if (update.selectionSet || update.docChanged) {
const pos = update.state.selection.main.head;
requestAnimationFrame(() => {
if (this.view.dom && this.view.dom.isConnected) {
this.view.dispatch({
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
}
}
});
}
}
});
}
);
// Line dimming ViewPlugin
function getOpacity(distance) {
if (distance === 0) return 1.0;
if (distance === 1) return 0.7;
if (distance === 2) return 0.6;
if (distance === 3) return 0.45;
return 0.3;
if (distance === 0) return 1.0;
if (distance === 1) return 0.7;
if (distance === 2) return 0.6;
if (distance === 3) return 0.45;
return 0.3;
}
const lineDimmingExtension = ViewPlugin.fromClass(class {
const lineDimmingExtension = ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
this.decorations = this.buildDecorations(view);
}
update(update) {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const builder = new RangeSetBuilder();
const pos = view.state.selection.main.head;
const activeLine = view.state.doc.lineAt(pos).number;
const doc = view.state.doc;
const builder = new RangeSetBuilder();
const pos = view.state.selection.main.head;
const activeLine = view.state.doc.lineAt(pos).number;
const doc = view.state.doc;
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
const line = doc.line(lineNum);
const distance = Math.abs(lineNum - activeLine);
const opacity = getOpacity(distance);
const deco = Decoration.line({
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
});
builder.add(line.from, line.from, deco);
}
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
const line = doc.line(lineNum);
const distance = Math.abs(lineNum - activeLine);
const opacity = getOpacity(distance);
const deco = Decoration.line({
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
});
builder.add(line.from, line.from, deco);
}
return builder.finish();
return builder.finish();
}
}, {
decorations: v => v.decorations,
});
},
{
decorations: (v) => v.decorations,
}
);
module.exports = { ZenMode };
+28
View File
@@ -0,0 +1,28 @@
/**
* Tests for bundled external tools
*/
const fs = require('fs');
const path = require('path');
describe('Bundled Pandoc binary', () => {
const rootDir = path.resolve(__dirname, '..');
const platform = process.platform;
const binaryPath = path.join(rootDir, 'bin', platform, 'pandoc');
const windowsBinaryPath = path.join(rootDir, 'bin', platform, 'pandoc.exe');
test('Pandoc binary exists for current platform', () => {
const exists = fs.existsSync(binaryPath) || fs.existsSync(windowsBinaryPath);
expect(exists).toBe(true);
});
test('Pandoc binary is executable', () => {
if (platform === 'win32') {
expect(fs.existsSync(windowsBinaryPath)).toBe(true);
} else {
expect(fs.existsSync(binaryPath)).toBe(true);
const stats = fs.statSync(binaryPath);
expect(stats.mode & 0o111).toBeGreaterThan(0);
}
});
});
+110 -110
View File
@@ -8,10 +8,10 @@
*/
describe('CommandPalette', () => {
let CommandPalette;
let CommandPalette;
beforeEach(() => {
document.body.innerHTML = `
beforeEach(() => {
document.body.innerHTML = `
<div class="command-palette-overlay hidden" id="command-palette-overlay">
<div class="command-palette">
<input type="text" id="command-palette-input">
@@ -19,126 +19,126 @@ describe('CommandPalette', () => {
</div>
</div>
`;
CommandPalette = require('../src/command-palette').CommandPalette;
});
CommandPalette = require('../src/command-palette').CommandPalette;
});
test('starts hidden', () => {
const palette = new CommandPalette();
expect(palette.isOpen()).toBe(false);
});
test('starts hidden', () => {
const palette = new CommandPalette();
expect(palette.isOpen()).toBe(false);
});
test('opens and focuses input', () => {
const palette = new CommandPalette();
palette.open();
expect(palette.isOpen()).toBe(true);
});
test('opens and focuses input', () => {
const palette = new CommandPalette();
palette.open();
expect(palette.isOpen()).toBe(true);
});
test('closes', () => {
const palette = new CommandPalette();
palette.open();
palette.close();
expect(palette.isOpen()).toBe(false);
});
test('closes', () => {
const palette = new CommandPalette();
palette.open();
palette.close();
expect(palette.isOpen()).toBe(false);
});
test('registers and renders commands', () => {
const palette = new CommandPalette();
palette.register('Test Command', 'Ctrl+T', () => {});
palette.register('Another Command', '', () => {});
palette.open();
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('registers and renders commands', () => {
const palette = new CommandPalette();
palette.register('Test Command', 'Ctrl+T', () => {});
palette.register('Another Command', '', () => {});
palette.open();
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('filters commands by search', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.register('Open File', 'Ctrl+O', () => {});
palette.register('Bold Text', 'Ctrl+B', () => {});
palette.renderResults('file');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('filters commands by search', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.register('Open File', 'Ctrl+O', () => {});
palette.register('Bold Text', 'Ctrl+B', () => {});
palette.renderResults('file');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('executes command', () => {
const palette = new CommandPalette();
const action = jest.fn();
palette.register('Test', '', action);
palette.open();
palette.executeSelected();
expect(action).toHaveBeenCalled();
});
test('executes command', () => {
const palette = new CommandPalette();
const action = jest.fn();
palette.register('Test', '', action);
palette.open();
palette.executeSelected();
expect(action).toHaveBeenCalled();
});
test('highlights matching text', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', 'save');
expect(result).toContain('<strong>');
expect(result).toContain('Save');
});
test('highlights matching text', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', 'save');
expect(result).toContain('<strong>');
expect(result).toContain('Save');
});
test('highlightMatch returns original text when no query', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', '');
expect(result).toBe('Save File');
});
test('highlightMatch returns original text when no query', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', '');
expect(result).toBe('Save File');
});
test('filters case-insensitively', () => {
const palette = new CommandPalette();
palette.register('Save File', '', () => {});
palette.register('Open File', '', () => {});
palette.renderResults('SAVE');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(1);
});
test('filters case-insensitively', () => {
const palette = new CommandPalette();
palette.register('Save File', '', () => {});
palette.register('Open File', '', () => {});
palette.renderResults('SAVE');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(1);
});
test('shows all commands when query is empty', () => {
const palette = new CommandPalette();
palette.register('Cmd1', '', () => {});
palette.register('Cmd2', '', () => {});
palette.register('Cmd3', '', () => {});
palette.renderResults('');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(3);
});
test('shows all commands when query is empty', () => {
const palette = new CommandPalette();
palette.register('Cmd1', '', () => {});
palette.register('Cmd2', '', () => {});
palette.register('Cmd3', '', () => {});
palette.renderResults('');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(3);
});
test('displays shortcut when provided', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).not.toBeNull();
expect(shortcut.textContent).toBe('Ctrl+S');
});
test('displays shortcut when provided', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).not.toBeNull();
expect(shortcut.textContent).toBe('Ctrl+S');
});
test('does not display shortcut when empty', () => {
const palette = new CommandPalette();
palette.register('Test Command', '', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).toBeNull();
});
test('does not display shortcut when empty', () => {
const palette = new CommandPalette();
palette.register('Test Command', '', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).toBeNull();
});
test('closes on overlay click', () => {
const palette = new CommandPalette();
palette.open();
// Simulate click on the overlay itself
const event = new Event('click', { bubbles: true });
Object.defineProperty(event, 'target', { value: palette.overlay });
palette.overlay.dispatchEvent(event);
expect(palette.isOpen()).toBe(false);
});
test('closes on overlay click', () => {
const palette = new CommandPalette();
palette.open();
// Simulate click on the overlay itself
const event = new Event('click', { bubbles: true });
Object.defineProperty(event, 'target', { value: palette.overlay });
palette.overlay.dispatchEvent(event);
expect(palette.isOpen()).toBe(false);
});
test('executeSelected does nothing when no commands', () => {
const palette = new CommandPalette();
palette.open();
// Should not throw
expect(() => palette.executeSelected()).not.toThrow();
});
test('executeSelected does nothing when no commands', () => {
const palette = new CommandPalette();
palette.open();
// Should not throw
expect(() => palette.executeSelected()).not.toThrow();
});
test('closes after executing command', () => {
const palette = new CommandPalette();
palette.register('Test', '', jest.fn());
palette.open();
palette.executeSelected();
expect(palette.isOpen()).toBe(false);
});
test('closes after executing command', () => {
const palette = new CommandPalette();
palette.register('Test', '', jest.fn());
palette.open();
palette.executeSelected();
expect(palette.isOpen()).toBe(false);
});
});
+3 -1
View File
@@ -48,7 +48,9 @@ describe('EventBus', () => {
test('handler errors are caught and logged, not thrown', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
bus.on('bad:event', () => { throw new Error('boom'); });
bus.on('bad:event', () => {
throw new Error('boom');
});
expect(() => bus.emit('bad:event', {})).not.toThrow();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
+11 -17
View File
@@ -8,14 +8,14 @@ describe('GitOperations Utilities', () => {
describe('error handling patterns', () => {
it('should handle git errors gracefully', () => {
const errorResponse = { error: 'Not a git repository' };
expect(errorResponse).toHaveProperty('error');
expect(errorResponse.error).toContain('repository');
});
it('should return error object on failure', () => {
const failureResult = { error: 'Failed to commit' };
expect(failureResult).toBeDefined();
expect(failureResult.error).toBeTruthy();
});
@@ -56,24 +56,20 @@ describe('GitOperations Utilities', () => {
'branch',
'checkout',
'push',
'pull'
'pull',
];
expect(gitOps.length).toBeGreaterThan(0);
gitOps.forEach(op => {
gitOps.forEach((op) => {
expect(typeof op).toBe('string');
expect(op.length).toBeGreaterThan(0);
});
});
it('should handle directory paths', () => {
const paths = [
'/home/user/project',
'./current/dir',
'../parent/dir'
];
const paths = ['/home/user/project', './current/dir', '../parent/dir'];
paths.forEach(pathStr => {
paths.forEach((pathStr) => {
expect(typeof pathStr).toBe('string');
expect(pathStr.length).toBeGreaterThan(0);
});
@@ -83,10 +79,10 @@ describe('GitOperations Utilities', () => {
const messages = [
'fix: bug in git panel',
'feat: add new feature',
'refactor: clean up code'
'refactor: clean up code',
];
messages.forEach(msg => {
messages.forEach((msg) => {
expect(typeof msg).toBe('string');
expect(msg.length).toBeGreaterThan(0);
});
@@ -101,7 +97,7 @@ describe('GitOperations Utilities', () => {
deleted: [],
modified: [],
renamed: [],
staged: ['file.md']
staged: ['file.md'],
};
expect(statusResponse).toHaveProperty('staged');
@@ -110,10 +106,8 @@ describe('GitOperations Utilities', () => {
it('should return log entries', () => {
const logResponse = {
all: [
{ hash: 'abc123', message: 'fix: something' }
],
latest: { hash: 'abc123', message: 'fix: something' }
all: [{ hash: 'abc123', message: 'fix: something' }],
latest: { hash: 'abc123', message: 'fix: something' },
};
expect(logResponse).toHaveProperty('all');
+3 -1
View File
@@ -8,7 +8,9 @@ describe('GoalTracker', () => {
store = {};
tracker = new GoalTracker({
get: (key) => store[key],
set: (key, value) => { store[key] = value; }
set: (key, value) => {
store[key] = value;
},
});
});
+18
View File
@@ -0,0 +1,18 @@
/**
* Tests ensuring the codebase has no ESLint warnings.
*/
const { execSync } = require('child_process');
const path = require('path');
describe('ESLint quality gate', () => {
test('src and tests have no ESLint warnings', () => {
const rootDir = path.resolve(__dirname, '..');
const result = execSync('npm run lint', {
cwd: rootDir,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
expect(result).not.toMatch(/warning/);
});
});
+76 -77
View File
@@ -4,97 +4,96 @@
*/
describe('sanitizeErrorMessage', () => {
const sanitizeErrorMessage = (message) => {
if (typeof message !== 'string') return String(message);
return message
.replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1')
.replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1');
};
const sanitizeErrorMessage = (message) => {
if (typeof message !== 'string') return String(message);
return message
.replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1')
.replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1');
};
test('strips Windows absolute paths', () => {
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js'))
.toBe('Error in file.js');
});
test('strips Windows absolute paths', () => {
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js')).toBe('Error in file.js');
});
test('strips Unix absolute paths', () => {
expect(sanitizeErrorMessage('Error in /home/user/project/file.js'))
.toBe('Error in file.js');
});
test('strips Unix absolute paths', () => {
expect(sanitizeErrorMessage('Error in /home/user/project/file.js')).toBe('Error in file.js');
});
test('handles non-string input', () => {
expect(sanitizeErrorMessage(42)).toBe('42');
expect(sanitizeErrorMessage(null)).toBe('null');
});
test('handles non-string input', () => {
expect(sanitizeErrorMessage(42)).toBe('42');
expect(sanitizeErrorMessage(null)).toBe('null');
});
test('preserves messages without paths', () => {
expect(sanitizeErrorMessage('Something went wrong'))
.toBe('Something went wrong');
});
test('preserves messages without paths', () => {
expect(sanitizeErrorMessage('Something went wrong')).toBe('Something went wrong');
});
test('strips nested Windows paths', () => {
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json'))
.toBe('Cannot read config.json');
});
test('strips nested Windows paths', () => {
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json')).toBe(
'Cannot read config.json'
);
});
test('strips nested Unix paths', () => {
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log'))
.toBe('File not found: error.log');
});
test('strips nested Unix paths', () => {
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log')).toBe(
'File not found: error.log'
);
});
test('handles undefined input', () => {
expect(sanitizeErrorMessage(undefined)).toBe('undefined');
});
test('handles undefined input', () => {
expect(sanitizeErrorMessage(undefined)).toBe('undefined');
});
});
describe('createRateLimiter', () => {
const createRateLimiter = (minIntervalMs = 2000) => {
let lastCall = 0;
return function canProceed() {
const now = Date.now();
if (now - lastCall < minIntervalMs) return false;
lastCall = now;
return true;
};
const createRateLimiter = (minIntervalMs = 2000) => {
let lastCall = 0;
return function canProceed() {
const now = Date.now();
if (now - lastCall < minIntervalMs) return false;
lastCall = now;
return true;
};
};
test('allows first call', () => {
const limiter = createRateLimiter(1000);
expect(limiter()).toBe(true);
});
test('allows first call', () => {
const limiter = createRateLimiter(1000);
expect(limiter()).toBe(true);
});
test('blocks rapid calls', () => {
const limiter = createRateLimiter(1000);
limiter(); // first call
expect(limiter()).toBe(false); // too soon
});
test('blocks rapid calls', () => {
const limiter = createRateLimiter(1000);
limiter(); // first call
expect(limiter()).toBe(false); // too soon
});
test('allows call after interval', () => {
jest.useFakeTimers();
const limiter = createRateLimiter(1000);
limiter();
jest.advanceTimersByTime(1001);
expect(limiter()).toBe(true);
jest.useRealTimers();
});
test('allows call after interval', () => {
jest.useFakeTimers();
const limiter = createRateLimiter(1000);
limiter();
jest.advanceTimersByTime(1001);
expect(limiter()).toBe(true);
jest.useRealTimers();
});
test('uses default interval of 2000ms', () => {
jest.useFakeTimers();
const limiter = createRateLimiter();
limiter();
jest.advanceTimersByTime(1999);
expect(limiter()).toBe(false);
jest.advanceTimersByTime(2);
expect(limiter()).toBe(true);
jest.useRealTimers();
});
test('uses default interval of 2000ms', () => {
jest.useFakeTimers();
const limiter = createRateLimiter();
limiter();
jest.advanceTimersByTime(1999);
expect(limiter()).toBe(false);
jest.advanceTimersByTime(2);
expect(limiter()).toBe(true);
jest.useRealTimers();
});
test('resets after successful call', () => {
jest.useFakeTimers();
const limiter = createRateLimiter(500);
limiter();
jest.advanceTimersByTime(501);
limiter(); // resets the timer
expect(limiter()).toBe(false); // too soon after second call
jest.useRealTimers();
});
test('resets after successful call', () => {
jest.useFakeTimers();
const limiter = createRateLimiter(500);
limiter();
jest.advanceTimersByTime(501);
limiter(); // resets the timer
expect(limiter()).toBe(false); // too soon after second call
jest.useRealTimers();
});
});
+236 -222
View File
@@ -4,245 +4,259 @@
*/
describe('Markdown Extensions', () => {
describe('TOC generation from headings', () => {
test('extracts all heading levels', () => {
const html = '<h1>Title</h1><h2>Section 1</h2><h2>Section 2</h2><h3>Subsection</h3>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(4);
expect(toc[0]).toEqual({ level: 1, text: 'Title' });
expect(toc[3]).toEqual({ level: 3, text: 'Subsection' });
});
test('handles empty HTML', () => {
const html = '<p>No headings here</p>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(0);
});
test('handles headings with attributes', () => {
const html = '<h1 id="title" class="main">Title</h1><h2 id="sec">Section</h2>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(2);
expect(toc[0].text).toBe('Title');
});
describe('TOC generation from headings', () => {
test('extracts all heading levels', () => {
const html = '<h1>Title</h1><h2>Section 1</h2><h2>Section 2</h2><h3>Subsection</h3>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(4);
expect(toc[0]).toEqual({ level: 1, text: 'Title' });
expect(toc[3]).toEqual({ level: 3, text: 'Subsection' });
});
describe('Admonition regex matching', () => {
test('matches note admonition', () => {
const src = ':::note\nThis is a note.\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[1]).toBe('note');
expect(match[2].trim()).toBe('This is a note.');
});
test('matches all admonition types', () => {
const types = ['note', 'warning', 'tip', 'danger', 'info'];
types.forEach(type => {
const src = `:::${type}\nContent\n:::\n`;
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[1]).toBe(type);
});
});
test('does not match invalid admonition type', () => {
const src = ':::custom\nContent\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).toBeNull();
});
test('captures multiline content', () => {
const src = ':::warning\nLine 1\nLine 2\nLine 3\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[2]).toContain('Line 1');
expect(match[2]).toContain('Line 3');
});
test('handles empty HTML', () => {
const html = '<p>No headings here</p>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(0);
});
describe('Admonitions full parsing integration (mocked environment)', () => {
const extension = {
name: 'admonition',
level: 'block',
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
tokenizer(src) {
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
if (match && match.index === 0) {
const admonitionType = match[1];
const text = match[2].trim();
const tokens = [];
this.lexer.blockTokens(text, tokens);
return {
type: 'admonition',
raw: match[0],
admonitionType,
text,
tokens
};
}
},
renderer(token) {
const icons = { note: '', warning: '⚠', tip: '💡', danger: '🔴', info: '' };
const icon = icons[token.admonitionType] || '';
const inner = this.parser.parse(token.tokens || []);
return `<div class="admonition admonition-${token.admonitionType}">
test('handles headings with attributes', () => {
const html = '<h1 id="title" class="main">Title</h1><h2 id="sec">Section</h2>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(2);
expect(toc[0].text).toBe('Title');
});
});
describe('Admonition regex matching', () => {
test('matches note admonition', () => {
const src = ':::note\nThis is a note.\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[1]).toBe('note');
expect(match[2].trim()).toBe('This is a note.');
});
test('matches all admonition types', () => {
const types = ['note', 'warning', 'tip', 'danger', 'info'];
types.forEach((type) => {
const src = `:::${type}\nContent\n:::\n`;
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[1]).toBe(type);
});
});
test('does not match invalid admonition type', () => {
const src = ':::custom\nContent\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).toBeNull();
});
test('captures multiline content', () => {
const src = ':::warning\nLine 1\nLine 2\nLine 3\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[2]).toContain('Line 1');
expect(match[2]).toContain('Line 3');
});
});
describe('Admonitions full parsing integration (mocked environment)', () => {
const extension = {
name: 'admonition',
level: 'block',
start(src) {
return src.match(/^:::(note|warning|tip|danger|info)/m)?.index;
},
tokenizer(src) {
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
if (match && match.index === 0) {
const admonitionType = match[1];
const text = match[2].trim();
const tokens = [];
this.lexer.blockTokens(text, tokens);
return {
type: 'admonition',
raw: match[0],
admonitionType,
text,
tokens,
};
}
},
renderer(token) {
const icons = { note: '', warning: '⚠', tip: '💡', danger: '🔴', info: '' };
const icon = icons[token.admonitionType] || '';
const inner = this.parser.parse(token.tokens || []);
return `<div class="admonition admonition-${token.admonitionType}">
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
<div class="admonition-content">${inner}</div>
</div>`;
}
};
},
};
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
const src = ':::note\nThis is a note.\n:::';
const mockLexer = {
blockTokens: jest.fn((text, tokens) => {
tokens.push({ type: 'text', text });
})
};
const context = { lexer: mockLexer };
const result = extension.tokenizer.call(context, src);
expect(result).toBeDefined();
expect(result.type).toBe('admonition');
expect(result.admonitionType).toBe('note');
expect(result.text).toBe('This is a note.');
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
});
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
const src = ':::note\nThis is a note.\n:::';
const mockLexer = {
blockTokens: jest.fn((text, tokens) => {
tokens.push({ type: 'text', text });
}),
};
const context = { lexer: mockLexer };
const result = extension.tokenizer.call(context, src);
test('renderer correctly translates tokens to HTML', () => {
const token = {
type: 'admonition',
admonitionType: 'warning',
tokens: [{ type: 'text', text: 'Be careful!' }]
};
const mockParser = {
parse: jest.fn((tokens) => '<p>Be careful!</p>')
};
const context = { parser: mockParser };
const html = extension.renderer.call(context, token);
expect(html).toContain('admonition admonition-warning');
expect(html).toContain('⚠ Warning');
expect(html).toContain('<p>Be careful!</p>');
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
});
expect(result).toBeDefined();
expect(result.type).toBe('admonition');
expect(result.admonitionType).toBe('note');
expect(result.text).toBe('This is a note.');
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
});
describe('PlantUML hex encoding', () => {
const plantumlEncode = (text) => {
const hex = Array.from(Buffer.from(text, 'utf-8'))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return '~h' + hex;
};
test('renderer correctly translates tokens to HTML', () => {
const token = {
type: 'admonition',
admonitionType: 'warning',
tokens: [{ type: 'text', text: 'Be careful!' }],
};
const mockParser = {
parse: jest.fn(() => '<p>Be careful!</p>'),
};
const context = { parser: mockParser };
const html = extension.renderer.call(context, token);
test('encodes simple text', () => {
const encoded = plantumlEncode('A -> B');
expect(encoded).toBe('~h41202d3e2042');
});
expect(html).toContain('admonition admonition-warning');
expect(html).toContain('⚠ Warning');
expect(html).toContain('<p>Be careful!</p>');
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
});
});
test('encodes empty string', () => {
expect(plantumlEncode('')).toBe('~h');
});
describe('PlantUML hex encoding', () => {
const plantumlEncode = (text) => {
const hex = Array.from(Buffer.from(text, 'utf-8'))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return '~h' + hex;
};
test('encodes special characters', () => {
const encoded = plantumlEncode('@startuml');
expect(encoded).toMatch(/^~h[0-9a-f]+$/);
// '@' is 0x40, 's' is 0x73
expect(encoded.startsWith('~h40')).toBe(true);
});
test('encodes simple text', () => {
const encoded = plantumlEncode('A -> B');
expect(encoded).toBe('~h41202d3e2042');
});
describe('Slug generation for TOC anchors', () => {
const slugify = (text) => {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
test('converts heading to slug', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('removes special characters', () => {
expect(slugify('What is C++?')).toBe('what-is-c');
});
test('collapses multiple dashes', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('handles already lowercase text', () => {
expect(slugify('simple')).toBe('simple');
});
test('encodes empty string', () => {
expect(plantumlEncode('')).toBe('~h');
});
describe('scopeCSS utility', () => {
const scopeCSS = (cssText, scopeSelector) => {
if (!cssText) return '';
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
const trimmed = selector.trim();
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
return match;
}
return scopeSelector + ' ' + trimmed + (separator || '');
});
};
test('scopes standard tag selector', () => {
const css = 'h1 { color: red; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe('.preview-content h1{ color: red; }');
});
test('scopes multiple class selectors', () => {
const css = '.title, .content { font-family: sans-serif; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe('.preview-content .title,.preview-content .content{ font-family: sans-serif; }');
});
test('ignores @rules like @media', () => {
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toContain('@media (max-width: 600px)');
});
test('ignores :root selector', () => {
const css = ':root { --color: red; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe(':root { --color: red; }');
});
test('ignores keyframe percentages', () => {
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toContain('0%');
expect(scoped).toContain('100%');
});
test('handles empty input', () => {
expect(scopeCSS('', '.preview-content')).toBe('');
expect(scopeCSS(null, '.preview-content')).toBe('');
});
test('encodes special characters', () => {
const encoded = plantumlEncode('@startuml');
expect(encoded).toMatch(/^~h[0-9a-f]+$/);
// '@' is 0x40, 's' is 0x73
expect(encoded.startsWith('~h40')).toBe(true);
});
});
describe('Slug generation for TOC anchors', () => {
const slugify = (text) => {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
test('converts heading to slug', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('removes special characters', () => {
expect(slugify('What is C++?')).toBe('what-is-c');
});
test('collapses multiple dashes', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('handles already lowercase text', () => {
expect(slugify('simple')).toBe('simple');
});
});
describe('scopeCSS utility', () => {
const scopeCSS = (cssText, scopeSelector) => {
if (!cssText) return '';
return cssText.replace(
/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g,
(match, selector, separator) => {
const trimmed = selector.trim();
if (
!trimmed ||
trimmed.startsWith('@') ||
trimmed.startsWith(':root') ||
trimmed.startsWith('from') ||
trimmed.startsWith('to') ||
/^\d+%$/.test(trimmed)
) {
return match;
}
return scopeSelector + ' ' + trimmed + (separator || '');
}
);
};
test('scopes standard tag selector', () => {
const css = 'h1 { color: red; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe('.preview-content h1{ color: red; }');
});
test('scopes multiple class selectors', () => {
const css = '.title, .content { font-family: sans-serif; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe(
'.preview-content .title,.preview-content .content{ font-family: sans-serif; }'
);
});
test('ignores @rules like @media', () => {
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toContain('@media (max-width: 600px)');
});
test('ignores :root selector', () => {
const css = ':root { --color: red; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe(':root { --color: red; }');
});
test('ignores keyframe percentages', () => {
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toContain('0%');
expect(scoped).toContain('100%');
});
test('handles empty input', () => {
expect(scopeCSS('', '.preview-content')).toBe('');
expect(scopeCSS(null, '.preview-content')).toBe('');
});
});
});
+234 -234
View File
@@ -9,272 +9,272 @@
const { ModalManager } = require('../src/utils/ModalManager');
function createModalElement(id = 'test-modal') {
const modal = document.createElement('div');
modal.id = id;
modal.className = 'modal hidden';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const modal = document.createElement('div');
modal.id = id;
modal.className = 'modal hidden';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.setAttribute('data-close', '');
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.setAttribute('data-close', '');
const content = document.createElement('div');
content.className = 'modal-content';
const content = document.createElement('div');
content.className = 'modal-content';
const header = document.createElement('div');
header.className = 'modal-header';
const header = document.createElement('div');
header.className = 'modal-header';
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close';
closeBtn.setAttribute('aria-label', 'Close');
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close';
closeBtn.setAttribute('aria-label', 'Close');
const body = document.createElement('div');
body.className = 'modal-body';
const body = document.createElement('div');
body.className = 'modal-body';
const input = document.createElement('input');
input.type = 'text';
const input = document.createElement('input');
input.type = 'text';
body.appendChild(input);
header.appendChild(closeBtn);
content.appendChild(header);
content.appendChild(body);
modal.appendChild(backdrop);
modal.appendChild(content);
document.body.appendChild(modal);
body.appendChild(input);
header.appendChild(closeBtn);
content.appendChild(header);
content.appendChild(body);
modal.appendChild(backdrop);
modal.appendChild(content);
document.body.appendChild(modal);
return modal;
return modal;
}
describe('ModalManager', () => {
let modal;
let manager;
let modal;
let manager;
beforeEach(() => {
modal = createModalElement();
manager = new ModalManager(modal);
});
afterEach(() => {
manager.destroy();
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
document.body.style.overflow = '';
});
// =========================================================
// open()
// =========================================================
describe('open()', () => {
test('removes hidden class and adds open class', () => {
expect(modal.classList.contains('hidden')).toBe(true);
expect(modal.classList.contains('open')).toBe(false);
manager.open();
expect(modal.classList.contains('hidden')).toBe(false);
expect(modal.classList.contains('open')).toBe(true);
});
test('sets isOpen to true', () => {
expect(manager.isOpen()).toBe(false);
manager.open();
expect(manager.isOpen()).toBe(true);
});
test('prevents body scroll', () => {
manager.open();
expect(document.body.style.overflow).toBe('hidden');
});
test('does not open again if already open', () => {
manager.open();
manager.open(); // second call should be no-op
expect(manager.isOpen()).toBe(true);
});
test('calls onOpen callback', () => {
const onOpen = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onOpen });
manager.open();
expect(onOpen).toHaveBeenCalledTimes(1);
});
test('dispatches modal:open custom event', () => {
const handler = jest.fn();
modal.addEventListener('modal:open', handler);
manager.open();
expect(handler).toHaveBeenCalledTimes(1);
});
});
// =========================================================
// close()
// =========================================================
describe('close()', () => {
beforeEach(() => {
modal = createModalElement();
manager = new ModalManager(modal);
manager.open();
});
afterEach(() => {
manager.destroy();
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
document.body.style.overflow = '';
test('removes open class', () => {
expect(modal.classList.contains('open')).toBe(true);
manager.close();
expect(modal.classList.contains('open')).toBe(false);
});
// =========================================================
// open()
// =========================================================
describe('open()', () => {
test('removes hidden class and adds open class', () => {
expect(modal.classList.contains('hidden')).toBe(true);
expect(modal.classList.contains('open')).toBe(false);
manager.open();
expect(modal.classList.contains('hidden')).toBe(false);
expect(modal.classList.contains('open')).toBe(true);
});
test('sets isOpen to true', () => {
expect(manager.isOpen()).toBe(false);
manager.open();
expect(manager.isOpen()).toBe(true);
});
test('prevents body scroll', () => {
manager.open();
expect(document.body.style.overflow).toBe('hidden');
});
test('does not open again if already open', () => {
manager.open();
manager.open(); // second call should be no-op
expect(manager.isOpen()).toBe(true);
});
test('calls onOpen callback', () => {
const onOpen = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onOpen });
manager.open();
expect(onOpen).toHaveBeenCalledTimes(1);
});
test('dispatches modal:open custom event', () => {
const handler = jest.fn();
modal.addEventListener('modal:open', handler);
manager.open();
expect(handler).toHaveBeenCalledTimes(1);
});
test('sets isOpen to false immediately', () => {
manager.close();
expect(manager.isOpen()).toBe(false);
});
// =========================================================
// close()
// =========================================================
describe('close()', () => {
beforeEach(() => {
manager.open();
});
test('removes open class', () => {
expect(modal.classList.contains('open')).toBe(true);
manager.close();
expect(modal.classList.contains('open')).toBe(false);
});
test('sets isOpen to false immediately', () => {
manager.close();
expect(manager.isOpen()).toBe(false);
});
test('restores body scroll when no modals remain open', () => {
manager.close();
expect(document.body.style.overflow).toBe('');
});
test('adds hidden class after transitionend event fires', () => {
manager.close();
// Immediately after close(): hidden should NOT yet be added —
// the close animation is still in progress.
// (This is the bug that existed before the fix.)
expect(modal.classList.contains('hidden')).toBe(false);
// Simulate the CSS transition completing
const event = new Event('transitionend');
Object.defineProperty(event, 'target', { value: modal, writable: false });
modal.dispatchEvent(event);
expect(modal.classList.contains('hidden')).toBe(true);
});
test('adds hidden class via 250ms timeout fallback when transitionend never fires', () => {
jest.useFakeTimers();
manager.close();
expect(modal.classList.contains('hidden')).toBe(false);
// Advance past the fallback timeout (250ms)
jest.advanceTimersByTime(300);
expect(modal.classList.contains('hidden')).toBe(true);
jest.useRealTimers();
});
test('does not add hidden class if modal is reopened before timeout fires', () => {
jest.useFakeTimers();
manager.close();
jest.advanceTimersByTime(100); // halfway through timeout
// Re-open the modal before the timeout fires
manager.open();
jest.advanceTimersByTime(200); // past original timeout expiry
// Modal was reopened, so hidden must NOT have been added
expect(modal.classList.contains('hidden')).toBe(false);
expect(modal.classList.contains('open')).toBe(true);
jest.useRealTimers();
});
test('calls onClose callback', () => {
const onClose = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onClose });
manager.open();
manager.close();
expect(onClose).toHaveBeenCalledTimes(1);
});
test('dispatches modal:close custom event', () => {
const handler = jest.fn();
modal.addEventListener('modal:close', handler);
manager.close();
expect(handler).toHaveBeenCalledTimes(1);
});
test('is a no-op when modal is already closed', () => {
manager.close(); // close from open
const onClose = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onClose });
manager.close(); // call close on an already-closed modal
expect(onClose).not.toHaveBeenCalled();
});
test('restores body scroll when no modals remain open', () => {
manager.close();
expect(document.body.style.overflow).toBe('');
});
// =========================================================
// Keyboard interaction
// =========================================================
test('adds hidden class after transitionend event fires', () => {
manager.close();
describe('keyboard shortcuts', () => {
test('Escape key closes an open modal', () => {
manager.open();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.isOpen()).toBe(false);
});
// Immediately after close(): hidden should NOT yet be added —
// the close animation is still in progress.
// (This is the bug that existed before the fix.)
expect(modal.classList.contains('hidden')).toBe(false);
test('Escape key does nothing when modal is already closed', () => {
expect(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}).not.toThrow();
});
// Simulate the CSS transition completing
const event = new Event('transitionend');
Object.defineProperty(event, 'target', { value: modal, writable: false });
modal.dispatchEvent(event);
test('closeOnEscape: false prevents Escape from closing', () => {
manager.destroy();
manager = new ModalManager(modal, { closeOnEscape: false });
manager.open();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.isOpen()).toBe(true);
});
expect(modal.classList.contains('hidden')).toBe(true);
});
// =========================================================
// Close triggers
// =========================================================
test('adds hidden class via 250ms timeout fallback when transitionend never fires', () => {
jest.useFakeTimers();
describe('close triggers', () => {
test('clicking the × close button closes the modal', () => {
manager.open();
modal.querySelector('.modal-close').click();
expect(manager.isOpen()).toBe(false);
});
manager.close();
expect(modal.classList.contains('hidden')).toBe(false);
test('clicking backdrop (data-close) closes the modal', () => {
manager.open();
modal.querySelector('.modal-backdrop').click();
expect(manager.isOpen()).toBe(false);
});
// Advance past the fallback timeout (250ms)
jest.advanceTimersByTime(300);
test('closeOnBackdrop: false prevents backdrop from closing', () => {
manager.destroy();
manager = new ModalManager(modal, { closeOnBackdrop: false });
manager.open();
modal.querySelector('.modal-backdrop').click();
expect(manager.isOpen()).toBe(true);
});
expect(modal.classList.contains('hidden')).toBe(true);
jest.useRealTimers();
});
// =========================================================
// destroy()
// =========================================================
test('does not add hidden class if modal is reopened before timeout fires', () => {
jest.useFakeTimers();
describe('destroy()', () => {
test('closes modal if open', () => {
manager.open();
manager.destroy();
expect(manager.isOpen()).toBe(false);
});
manager.close();
jest.advanceTimersByTime(100); // halfway through timeout
test('does not throw when destroying a closed modal', () => {
expect(() => manager.destroy()).not.toThrow();
});
// Re-open the modal before the timeout fires
manager.open();
jest.advanceTimersByTime(200); // past original timeout expiry
// Modal was reopened, so hidden must NOT have been added
expect(modal.classList.contains('hidden')).toBe(false);
expect(modal.classList.contains('open')).toBe(true);
jest.useRealTimers();
});
test('calls onClose callback', () => {
const onClose = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onClose });
manager.open();
manager.close();
expect(onClose).toHaveBeenCalledTimes(1);
});
test('dispatches modal:close custom event', () => {
const handler = jest.fn();
modal.addEventListener('modal:close', handler);
manager.close();
expect(handler).toHaveBeenCalledTimes(1);
});
test('is a no-op when modal is already closed', () => {
manager.close(); // close from open
const onClose = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onClose });
manager.close(); // call close on an already-closed modal
expect(onClose).not.toHaveBeenCalled();
});
});
// =========================================================
// Keyboard interaction
// =========================================================
describe('keyboard shortcuts', () => {
test('Escape key closes an open modal', () => {
manager.open();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.isOpen()).toBe(false);
});
test('Escape key does nothing when modal is already closed', () => {
expect(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}).not.toThrow();
});
test('closeOnEscape: false prevents Escape from closing', () => {
manager.destroy();
manager = new ModalManager(modal, { closeOnEscape: false });
manager.open();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.isOpen()).toBe(true);
});
});
// =========================================================
// Close triggers
// =========================================================
describe('close triggers', () => {
test('clicking the × close button closes the modal', () => {
manager.open();
modal.querySelector('.modal-close').click();
expect(manager.isOpen()).toBe(false);
});
test('clicking backdrop (data-close) closes the modal', () => {
manager.open();
modal.querySelector('.modal-backdrop').click();
expect(manager.isOpen()).toBe(false);
});
test('closeOnBackdrop: false prevents backdrop from closing', () => {
manager.destroy();
manager = new ModalManager(modal, { closeOnBackdrop: false });
manager.open();
modal.querySelector('.modal-backdrop').click();
expect(manager.isOpen()).toBe(true);
});
});
// =========================================================
// destroy()
// =========================================================
describe('destroy()', () => {
test('closes modal if open', () => {
manager.open();
manager.destroy();
expect(manager.isOpen()).toBe(false);
});
test('does not throw when destroying a closed modal', () => {
expect(() => manager.destroy()).not.toThrow();
});
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* Tests ensuring production code does not contain leftover diagnostic console.log statements.
*/
const fs = require('fs');
const path = require('path');
describe('No leftover diagnostic console.log statements', () => {
const rootDir = path.resolve(__dirname, '..');
const filesToCheck = ['src/main.js', 'src/renderer.js'];
filesToCheck.forEach((relativePath) => {
test(`${relativePath} has no console.log statements`, () => {
const content = fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
const matches = content.match(/console\.log\(/g) || [];
expect(matches).toHaveLength(0);
});
});
});
+10 -13
View File
@@ -8,27 +8,24 @@ describe('PDFOperations Utilities', () => {
describe('page range parsing', () => {
it('should parse single page numbers', () => {
// Test logic: parsing "1" should extract page index 0
const input = '1';
const pages = [0]; // Parsed result
expect(pages.length).toBe(1);
expect(pages[0]).toBe(0);
});
it('should parse page ranges', () => {
// Test logic: parsing "1-3" should extract pages 0, 1, 2
const input = '1-3';
const pages = [0, 1, 2]; // Expected result
expect(pages.length).toBe(3);
expect(pages).toEqual([0, 1, 2]);
});
it('should handle multiple ranges', () => {
// Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4
const input = '1-2,4-5';
const pages = [0, 1, 3, 4]; // Expected result
expect(pages.length).toBe(4);
expect(pages).toEqual([0, 1, 3, 4]);
});
@@ -36,7 +33,7 @@ describe('PDFOperations Utilities', () => {
it('should sort pages in ascending order', () => {
const unsorted = [2, 0, 3, 1];
const sorted = unsorted.sort((a, b) => a - b);
expect(sorted).toEqual([0, 1, 2, 3]);
});
});
@@ -45,14 +42,14 @@ describe('PDFOperations Utilities', () => {
it('should validate hex color format', () => {
const validHex = '#FF5733';
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(validHex);
expect(isValid).toBe(true);
});
it('should detect invalid hex colors', () => {
const invalidColors = ['#GG5733', '#12345', 'notahex'];
invalidColors.forEach(color => {
invalidColors.forEach((color) => {
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(color);
expect(isValid).toBe(false);
});
@@ -73,12 +70,12 @@ describe('PDFOperations Utilities', () => {
{ input: '-1', isValid: false },
{ input: 'abc', isValid: false },
{ input: '1', isValid: true },
{ input: '5', isValid: true }
{ input: '5', isValid: true },
];
testPages.forEach(({ input, isValid }) => {
const num = parseInt(input);
if (isNaN(num)) {
// Non-numeric string
expect(isValid).toBe(false);
+15 -4
View File
@@ -13,9 +13,14 @@ describe('PluginContext', () => {
statusBar: { registerIndicator: jest.fn() },
eventBus: new EventBus(),
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
editor: { getContent: jest.fn(), getSelection: jest.fn(), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
editor: {
getContent: jest.fn(),
getSelection: jest.fn(),
insertAtCursor: jest.fn(),
onContentChanged: jest.fn(),
},
ipc: { invoke: jest.fn(), on: jest.fn() },
exportHooks: { preHooks: [], postHooks: [] }
exportHooks: { preHooks: [], postHooks: [] },
};
context = new PluginContext(mockDeps);
});
@@ -23,11 +28,17 @@ describe('PluginContext', () => {
test('exposes sidebar.registerPanel with namespaced id', () => {
const handler = jest.fn();
context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler });
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', { icon: 'test', title: 'Test', render: handler });
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', {
icon: 'test',
title: 'Test',
render: handler,
});
});
test('exposes commands.register with crash-safe wrapper', () => {
const badHandler = () => { throw new Error('boom'); };
const badHandler = () => {
throw new Error('boom');
};
context.commands.register('bad-cmd', 'Bad', badHandler, 'Ctrl+Alt+T');
const registeredHandler = mockDeps.commands.register.mock.calls[0][2];
expect(() => registeredHandler()).not.toThrow();
+21 -7
View File
@@ -25,7 +25,7 @@ describe('PluginLoader', () => {
id: 'my-plugin',
name: 'My Plugin',
version: '1.0.0',
description: 'Test'
description: 'Test',
});
const loader = new PluginLoader([tempDir]);
const plugins = loader.discoverPlugins();
@@ -46,7 +46,7 @@ describe('PluginLoader', () => {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
description: 'A test plugin'
description: 'A test plugin',
};
const loader = new PluginLoader([]);
expect(loader.validateManifest(manifest)).toBe(true);
@@ -67,18 +67,32 @@ describe('PluginLoader', () => {
test('validateManifest — rejects manifest with duplicate id', () => {
const loader = new PluginLoader([]);
loader.loadedIds = new Set(['existing-plugin']);
expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' }))
.toThrow(/duplicate/i);
expect(() =>
loader.validateManifest({
id: 'existing-plugin',
name: 'Dup',
version: '1.0.0',
description: 'x',
})
).toThrow(/duplicate/i);
});
test('discoverPlugins — loads index.js Plugin export if present', () => {
const pluginDir = path.join(tempDir, 'with-index');
writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' });
writeManifest(pluginDir, {
id: 'with-index',
name: 'With Index',
version: '1.0.0',
description: 'Test',
});
// Write a simple index.js with a Plugin export
fs.writeFileSync(path.join(pluginDir, 'index.js'), `
fs.writeFileSync(
path.join(pluginDir, 'index.js'),
`
class SimplePlugin { init() {} }
module.exports = { Plugin: SimplePlugin };
`);
`
);
const loader = new PluginLoader([tempDir]);
const plugins = loader.discoverPlugins();
expect(plugins).toHaveLength(1);
+86 -17
View File
@@ -3,9 +3,16 @@ const { PluginAPI } = require('../src/plugins/plugin-api');
const { EventBus } = require('../src/plugins/event-bus');
class TestPlugin extends PluginAPI {
init(context) { this.initialized = true; this.ctx = context; }
activate() { this.activated = true; }
deactivate() { this.deactivated = true; }
init(context) {
this.initialized = true;
this.ctx = context;
}
activate() {
this.activated = true;
}
deactivate() {
this.deactivated = true;
}
}
describe('PluginRegistry', () => {
@@ -19,16 +26,26 @@ describe('PluginRegistry', () => {
statusBar: { registerIndicator: jest.fn() },
eventBus: new EventBus(),
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
editor: { getContent: jest.fn(() => ''), getSelection: jest.fn(() => ''), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
ipc: { invoke: jest.fn(), on: jest.fn() }
editor: {
getContent: jest.fn(() => ''),
getSelection: jest.fn(() => ''),
insertAtCursor: jest.fn(),
onContentChanged: jest.fn(),
},
ipc: { invoke: jest.fn(), on: jest.fn() },
};
registry = new PluginRegistry(mockDeps);
});
test('register — stores plugin and calls init', () => {
registry.register({
id: 'test', name: 'Test', version: '1.0.0', description: 'desc',
manifest: {}, PluginClass: TestPlugin, dir: '/tmp/test'
id: 'test',
name: 'Test',
version: '1.0.0',
description: 'desc',
manifest: {},
PluginClass: TestPlugin,
dir: '/tmp/test',
});
const entry = registry.getPlugin('test');
expect(entry).toBeDefined();
@@ -37,20 +54,32 @@ describe('PluginRegistry', () => {
test('register — works without PluginClass', () => {
registry.register({
id: 'manifest-only', name: 'Manifest Only', version: '1.0.0', description: 'desc',
manifest: {}, PluginClass: null, dir: '/tmp/test'
id: 'manifest-only',
name: 'Manifest Only',
version: '1.0.0',
description: 'desc',
manifest: {},
PluginClass: null,
dir: '/tmp/test',
});
expect(registry.getPlugin('manifest-only')).toBeDefined();
});
test('register — init error does not crash, plugin not registered', () => {
class BadPlugin extends PluginAPI {
init() { throw new Error('init fail'); }
init() {
throw new Error('init fail');
}
}
expect(() => {
registry.register({
id: 'bad', name: 'Bad', version: '1.0.0', description: 'desc',
manifest: {}, PluginClass: BadPlugin, dir: '/tmp/test'
id: 'bad',
name: 'Bad',
version: '1.0.0',
description: 'desc',
manifest: {},
PluginClass: BadPlugin,
dir: '/tmp/test',
});
}).not.toThrow();
expect(registry.getPlugin('bad')).toBeUndefined();
@@ -61,25 +90,65 @@ describe('PluginRegistry', () => {
});
test('getAll — returns all registered plugins', () => {
registry.register({ id: 'a', name: 'A', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
registry.register({ id: 'b', name: 'B', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
registry.register({
id: 'a',
name: 'A',
version: '1',
description: '',
manifest: {},
PluginClass: null,
dir: '',
});
registry.register({
id: 'b',
name: 'B',
version: '1',
description: '',
manifest: {},
PluginClass: null,
dir: '',
});
expect(registry.getAll()).toHaveLength(2);
});
test('activate — calls activate on plugin instance', () => {
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
registry.register({
id: 'test',
name: 'Test',
version: '1',
description: '',
manifest: {},
PluginClass: TestPlugin,
dir: '',
});
registry.activate('test');
expect(registry.getPlugin('test').instance.activated).toBe(true);
});
test('deactivate — calls deactivate on plugin instance', () => {
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
registry.register({
id: 'test',
name: 'Test',
version: '1',
description: '',
manifest: {},
PluginClass: TestPlugin,
dir: '',
});
registry.deactivate('test');
expect(registry.getPlugin('test').instance.deactivated).toBe(true);
});
test('exportHooks are available and populated', () => {
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
registry.register({
id: 'test',
name: 'Test',
version: '1',
description: '',
manifest: {},
PluginClass: TestPlugin,
dir: '',
});
const handler = jest.fn();
registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler);
expect(registry.exportHooks.preHooks).toContain(handler);
+2 -2
View File
@@ -74,7 +74,7 @@ describe('Preload Security', () => {
'execute-code',
'show-pdf-editor-from-toolbar',
'menu-open',
'export'
'export',
];
const EXPECTED_RECEIVE_CHANNELS = [
@@ -127,7 +127,7 @@ describe('Preload Security', () => {
'load-template-menu',
'toggle-command-palette',
'toggle-sidebar-panel',
'toggle-bottom-panel'
'toggle-bottom-panel',
];
test('should define all expected send channels', () => {
+82 -70
View File
@@ -8,13 +8,17 @@
*/
describe('PrintPreview', () => {
beforeEach(() => {
// Mock electron require for executePrint
jest.mock('electron', () => ({
ipcRenderer: { send: jest.fn(), invoke: jest.fn() }
}), { virtual: true });
beforeEach(() => {
// Mock electron require for executePrint
jest.mock(
'electron',
() => ({
ipcRenderer: { send: jest.fn(), invoke: jest.fn() },
}),
{ virtual: true }
);
document.body.innerHTML = `
document.body.innerHTML = `
<div class="dialog-overlay hidden" id="print-preview-overlay">
<button id="print-preview-close"></button>
<button id="print-cancel"></button>
@@ -31,79 +35,87 @@ describe('PrintPreview', () => {
<iframe id="print-preview-frame"></iframe>
</div>
`;
});
});
test('getOptions returns default values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
const options = preview.getOptions();
expect(options.paperSize).toBe('A4');
expect(options.orientation).toBe('portrait');
expect(options.scale).toBe(100);
expect(options.headers).toBe(true);
expect(options.background).toBe(true);
expect(options.pages).toBe('all');
expect(options.margins).toBe('default');
expect(options.pageRange).toBe('');
});
test('getOptions returns default values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
const options = preview.getOptions();
expect(options.paperSize).toBe('A4');
expect(options.orientation).toBe('portrait');
expect(options.scale).toBe(100);
expect(options.headers).toBe(true);
expect(options.background).toBe(true);
expect(options.pages).toBe('all');
expect(options.margins).toBe('default');
expect(options.pageRange).toBe('');
});
test('opens and closes', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(false);
preview.close();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
});
test('opens and closes', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
false
);
preview.close();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
true
);
});
test('updateScaleLabel reflects slider value', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
document.getElementById('print-scale').value = '75';
preview.updateScaleLabel();
expect(document.getElementById('print-scale-value').textContent).toBe('75%');
});
test('updateScaleLabel reflects slider value', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
document.getElementById('print-scale').value = '75';
preview.updateScaleLabel();
expect(document.getElementById('print-scale-value').textContent).toBe('75%');
});
test('close button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-preview-close').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
});
test('close button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-preview-close').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
true
);
});
test('cancel button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-cancel').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
});
test('cancel button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-cancel').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
true
);
});
test('getOptions reflects changed values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
test('getOptions reflects changed values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
// Change paper size to Letter
const paperSelect = document.getElementById('print-paper-size');
paperSelect.value = 'Letter';
// Change paper size to Letter
const paperSelect = document.getElementById('print-paper-size');
paperSelect.value = 'Letter';
// Change scale
document.getElementById('print-scale').value = '50';
// Change scale
document.getElementById('print-scale').value = '50';
// Uncheck headers
document.getElementById('print-headers').checked = false;
// Uncheck headers
document.getElementById('print-headers').checked = false;
const options = preview.getOptions();
expect(options.paperSize).toBe('Letter');
expect(options.scale).toBe(50);
expect(options.headers).toBe(false);
});
const options = preview.getOptions();
expect(options.paperSize).toBe('Letter');
expect(options.scale).toBe(50);
expect(options.headers).toBe(false);
});
test('stores last content for refresh', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Hello World</p>');
expect(preview._lastContent).toBe('<p>Hello World</p>');
});
test('stores last content for refresh', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Hello World</p>');
expect(preview._lastContent).toBe('<p>Hello World</p>');
});
});
+47 -19
View File
@@ -8,15 +8,22 @@ describe('ProjectManager', () => {
files = {};
pm = new ProjectManager({
readFile: (p) => files[p] || null,
writeFile: (p, c) => { files[p] = c; },
writeFile: (p, c) => {
files[p] = c;
},
fileExists: (p) => p in files,
listDir: (p) => Object.keys(files).filter(f => f.startsWith(p)).map(f => f.slice(p.length + 1))
listDir: (p) =>
Object.keys(files)
.filter((f) => f.startsWith(p))
.map((f) => f.slice(p.length + 1)),
});
});
test('createProject writes .project.json', () => {
const project = pm.createProject('/manuscripts/novel', {
title: 'My Novel', type: 'manuscript', targetWords: 80000
title: 'My Novel',
type: 'manuscript',
targetWords: 80000,
});
expect(project.title).toBe('My Novel');
expect(files['/manuscripts/novel/.project.json']).toBeDefined();
@@ -26,8 +33,11 @@ describe('ProjectManager', () => {
test('loadProject reads and returns project data', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
chapters: [], metadata: {}
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [],
metadata: {},
});
const project = pm.loadProject('/manuscripts/novel');
expect(project.title).toBe('Test');
@@ -39,10 +49,17 @@ describe('ProjectManager', () => {
test('addChapter appends chapter and saves', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
chapters: [], metadata: {}
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [],
metadata: {},
});
pm.addChapter('/manuscripts/novel', {
file: '01-chapter.md',
title: 'Chapter One',
status: 'draft',
});
pm.addChapter('/manuscripts/novel', { file: '01-chapter.md', title: 'Chapter One', status: 'draft' });
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
expect(parsed.chapters.length).toBe(1);
expect(parsed.chapters[0].title).toBe('Chapter One');
@@ -50,11 +67,14 @@ describe('ProjectManager', () => {
test('compileManuscript concatenates chapter files', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [
{ file: '01.md', title: 'One', status: 'draft' },
{ file: '02.md', title: 'Two', status: 'draft' }
], metadata: {}
{ file: '02.md', title: 'Two', status: 'draft' },
],
metadata: {},
});
files['/manuscripts/novel/01.md'] = 'First chapter content.';
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
@@ -64,11 +84,14 @@ describe('ProjectManager', () => {
test('compileManuscript skips missing files', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [
{ file: '01.md', title: 'One', status: 'draft' },
{ file: '02.md', title: 'Two', status: 'draft' }
], metadata: {}
{ file: '02.md', title: 'Two', status: 'draft' },
],
metadata: {},
});
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
const result = pm.compileManuscript('/manuscripts/novel');
@@ -77,11 +100,14 @@ describe('ProjectManager', () => {
test('getStats returns total word count across chapters', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [
{ file: '01.md', title: 'One', status: 'draft' },
{ file: '02.md', title: 'Two', status: 'draft' }
], metadata: {}
{ file: '02.md', title: 'Two', status: 'draft' },
],
metadata: {},
});
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
@@ -94,9 +120,11 @@ describe('ProjectManager', () => {
test('updateChapter modifies a chapter by index', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
metadata: {}
metadata: {},
});
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
+21
View File
@@ -0,0 +1,21 @@
/**
* Tests for project metadata consistency
*/
const fs = require('fs');
const path = require('path');
describe('Project version consistency', () => {
const rootDir = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'));
const readme = fs.readFileSync(path.join(rootDir, 'README.md'), 'utf8');
test('README version matches package.json version', () => {
const version = packageJson.version;
expect(readme).toContain(`v${version}`);
});
test('README contains current version string', () => {
expect(readme).toContain('v4.4.4');
});
});
+14 -31
View File
@@ -8,25 +8,18 @@ const path = require('path');
describe('Security: Path Handling', () => {
describe('path traversal prevention', () => {
it('should detect path traversal patterns', () => {
const maliciousPaths = [
'../etc/passwd',
'../../sensitive',
'./../outside'
];
const maliciousPaths = ['../etc/passwd', '../../sensitive', './../outside'];
maliciousPaths.forEach(pathStr => {
maliciousPaths.forEach((pathStr) => {
// Path traversal attempts contain .. patterns
expect(pathStr).toMatch(/\.\./);
});
});
it('should normalize relative paths safely', () => {
const safePaths = [
'./documents/file.md',
'relative/path/file.txt'
];
const safePaths = ['./documents/file.md', 'relative/path/file.txt'];
safePaths.forEach(pathStr => {
safePaths.forEach((pathStr) => {
const normalized = path.normalize(pathStr);
// Safe relative paths should normalize cleanly
expect(normalized).toBeDefined();
@@ -37,7 +30,7 @@ describe('Security: Path Handling', () => {
it('should detect absolute paths', () => {
const absolutePath = '/etc/passwd';
const isAbsolute = path.isAbsolute(absolutePath);
// Linux/Mac: /path is absolute
if (process.platform !== 'win32') {
expect(isAbsolute).toBe(true);
@@ -47,9 +40,9 @@ describe('Security: Path Handling', () => {
it('should safely join paths with base directory', () => {
const baseDir = '/safe/base/directory';
const userInput = 'documents/file.md';
const joined = path.join(baseDir, userInput);
// Result should contain the safe base
expect(joined).toContain('base');
expect(joined).toContain('documents');
@@ -58,14 +51,9 @@ describe('Security: Path Handling', () => {
describe('filename safety', () => {
it('should identify safe filenames', () => {
const safeNames = [
'document.md',
'my-file.txt',
'file_name.pdf',
'report_2026_04_24.xlsx'
];
const safeNames = ['document.md', 'my-file.txt', 'file_name.pdf', 'report_2026_04_24.xlsx'];
safeNames.forEach(name => {
safeNames.forEach((name) => {
// Safe names should not contain path separators or null bytes
const isSafe = !/[\\/\0]/.test(name) && name.length > 0;
expect(isSafe).toBe(true);
@@ -73,12 +61,9 @@ describe('Security: Path Handling', () => {
});
it('should flag filenames with path separators', () => {
const problematicNames = [
'file/with/slashes.txt',
'file\\with\\backslashes.txt'
];
const problematicNames = ['file/with/slashes.txt', 'file\\with\\backslashes.txt'];
problematicNames.forEach(name => {
problematicNames.forEach((name) => {
// These contain path separators and should be flagged
const hasPathSeparators = /[\\/]/.test(name);
expect(hasPathSeparators).toBe(true);
@@ -87,7 +72,7 @@ describe('Security: Path Handling', () => {
it('should enforce minimum filename length', () => {
const emptyName = '';
expect(emptyName.length).toBe(0);
expect(emptyName.length > 0).toBe(false);
});
@@ -95,10 +80,8 @@ describe('Security: Path Handling', () => {
describe('validation patterns', () => {
it('should validate path existence check pattern', () => {
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
validPaths.forEach(pathStr => {
validPaths.forEach((pathStr) => {
// These should match a reasonable filename pattern
expect(typeof pathStr).toBe('string');
});
@@ -106,7 +89,7 @@ describe('Security: Path Handling', () => {
it('should prevent null byte injection', () => {
const pathWithNullByte = 'file.txt\0.exe';
const isSafe = !pathWithNullByte.includes('\0');
expect(isSafe).toBe(false); // Has null byte, not safe
});
+3 -1
View File
@@ -8,7 +8,9 @@ describe('SettingsStore', () => {
data = {};
store = new SettingsStore({
get: (key) => data[key],
set: (key, value) => { data[key] = value; }
set: (key, value) => {
data[key] = value;
},
});
});
+15 -15
View File
@@ -25,61 +25,61 @@ global.window.electronAPI = {
exists: jest.fn(() => Promise.resolve(false)),
isDirectory: jest.fn(() => Promise.resolve(false)),
copy: jest.fn(() => Promise.resolve()),
move: jest.fn(() => Promise.resolve())
move: jest.fn(() => Promise.resolve()),
},
theme: {
get: jest.fn()
get: jest.fn(),
},
print: {
doPrint: jest.fn()
doPrint: jest.fn(),
},
export: {
withOptions: jest.fn(),
spreadsheet: jest.fn()
spreadsheet: jest.fn(),
},
batch: {
convert: jest.fn(),
selectFolder: jest.fn()
selectFolder: jest.fn(),
},
converter: {
convert: jest.fn(),
convertBatch: jest.fn()
convertBatch: jest.fn(),
},
headerFooter: {
getSettings: jest.fn(),
saveSettings: jest.fn(),
browseLogo: jest.fn(),
saveLogo: jest.fn(),
clearLogo: jest.fn()
clearLogo: jest.fn(),
},
page: {
getSettings: jest.fn(),
updateSettings: jest.fn(),
setCustomStartPage: jest.fn()
setCustomStartPage: jest.fn(),
},
pdf: {
processOperation: jest.fn(),
getPageCount: jest.fn(),
selectFolder: jest.fn()
}
selectFolder: jest.fn(),
},
};
// Mock marked library
global.window.marked = {
parse: jest.fn((text) => `<p>${text}</p>`),
use: jest.fn()
use: jest.fn(),
};
// Mock DOMPurify
global.window.DOMPurify = {
sanitize: jest.fn((html) => html)
sanitize: jest.fn((html) => html),
};
// Mock highlight.js
global.window.hljs = {
highlight: jest.fn((code, options) => ({ value: code })),
highlight: jest.fn((code, _options) => ({ value: code })),
highlightAuto: jest.fn((code) => ({ value: code })),
getLanguage: jest.fn(() => true)
getLanguage: jest.fn(() => true),
};
// Mock localStorage
@@ -87,7 +87,7 @@ const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn()
clear: jest.fn(),
};
global.localStorage = localStorageMock;
+97 -82
View File
@@ -8,10 +8,10 @@
*/
describe('SidebarManager', () => {
let SidebarManager;
let SidebarManager;
beforeEach(() => {
document.body.innerHTML = `
beforeEach(() => {
document.body.innerHTML = `
<div class="sidebar collapsed" id="sidebar">
<div class="sidebar-icons">
<button class="sidebar-icon" data-panel="test1"></button>
@@ -26,96 +26,111 @@ describe('SidebarManager', () => {
</div>
</div>
`;
SidebarManager = require('../src/sidebar/sidebar-manager').SidebarManager;
});
SidebarManager = require('../src/sidebar/sidebar-manager').SidebarManager;
});
test('starts collapsed', () => {
const mgr = new SidebarManager();
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
});
test('starts collapsed', () => {
new SidebarManager();
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
});
test('expands on panel toggle', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: (c) => { c.innerHTML = 'hello'; } });
mgr.togglePanel('test1');
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(false);
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Test 1');
test('expands on panel toggle', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', {
title: 'Test 1',
render: (c) => {
c.innerHTML = 'hello';
},
});
mgr.togglePanel('test1');
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(false);
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Test 1');
});
test('collapses on second toggle', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
mgr.togglePanel('test1');
mgr.togglePanel('test1');
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
});
test('collapses on second toggle', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
mgr.togglePanel('test1');
mgr.togglePanel('test1');
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
});
test('switches panels', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Panel 1', render: (c) => { c.innerHTML = 'one'; } });
mgr.registerPanel('test2', { title: 'Panel 2', render: (c) => { c.innerHTML = 'two'; } });
mgr.togglePanel('test1');
mgr.togglePanel('test2');
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Panel 2');
expect(document.getElementById('sidebar-panel-content').innerHTML).toBe('two');
test('switches panels', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', {
title: 'Panel 1',
render: (c) => {
c.innerHTML = 'one';
},
});
mgr.registerPanel('test2', {
title: 'Panel 2',
render: (c) => {
c.innerHTML = 'two';
},
});
mgr.togglePanel('test1');
mgr.togglePanel('test2');
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Panel 2');
expect(document.getElementById('sidebar-panel-content').innerHTML).toBe('two');
});
test('collapse resets active panel', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test', render: () => {} });
mgr.expand('test1');
mgr.collapse();
expect(mgr.activePanel).toBe(null);
});
test('collapse resets active panel', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test', render: () => {} });
mgr.expand('test1');
mgr.collapse();
expect(mgr.activePanel).toBe(null);
});
test('expand sets active icon', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
mgr.expand('test1');
const btn = document.querySelector('[data-panel="test1"]');
expect(btn.classList.contains('active')).toBe(true);
});
test('expand sets active icon', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
mgr.expand('test1');
const btn = document.querySelector('[data-panel="test1"]');
expect(btn.classList.contains('active')).toBe(true);
});
test('collapse removes active icon', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
mgr.expand('test1');
mgr.collapse();
const btn = document.querySelector('[data-panel="test1"]');
expect(btn.classList.contains('active')).toBe(false);
});
test('collapse removes active icon', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
mgr.expand('test1');
mgr.collapse();
const btn = document.querySelector('[data-panel="test1"]');
expect(btn.classList.contains('active')).toBe(false);
});
test('expand with unregistered panel does nothing', () => {
const mgr = new SidebarManager();
mgr.expand('nonexistent');
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
expect(mgr.activePanel).toBe(null);
});
test('expand with unregistered panel does nothing', () => {
const mgr = new SidebarManager();
mgr.expand('nonexistent');
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
expect(mgr.activePanel).toBe(null);
});
test('render function receives panel content element', () => {
const mgr = new SidebarManager();
const renderFn = jest.fn();
mgr.registerPanel('test1', { title: 'Test 1', render: renderFn });
mgr.expand('test1');
expect(renderFn).toHaveBeenCalledWith(document.getElementById('sidebar-panel-content'));
});
test('render function receives panel content element', () => {
const mgr = new SidebarManager();
const renderFn = jest.fn();
mgr.registerPanel('test1', { title: 'Test 1', render: renderFn });
mgr.expand('test1');
expect(renderFn).toHaveBeenCalledWith(document.getElementById('sidebar-panel-content'));
});
test('clicking sidebar icon toggles panel', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
const btn = document.querySelector('[data-panel="test1"]');
btn.click();
expect(mgr.activePanel).toBe('test1');
btn.click();
expect(mgr.activePanel).toBe(null);
});
test('clicking sidebar icon toggles panel', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
const btn = document.querySelector('[data-panel="test1"]');
btn.click();
expect(mgr.activePanel).toBe('test1');
btn.click();
expect(mgr.activePanel).toBe(null);
});
test('clicking close button collapses sidebar', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
mgr.expand('test1');
document.querySelector('.sidebar-panel-close').click();
expect(mgr.activePanel).toBe(null);
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
});
test('clicking close button collapses sidebar', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: () => {} });
mgr.expand('test1');
document.querySelector('.sidebar-panel-close').click();
expect(mgr.activePanel).toBe(null);
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
});
});
+3 -1
View File
@@ -8,7 +8,9 @@ describe('SnapshotManager', () => {
store = {};
manager = new SnapshotManager({
get: (key) => store[key],
set: (key, value) => { store[key] = value; }
set: (key, value) => {
store[key] = value;
},
});
});
+2 -2
View File
@@ -7,7 +7,7 @@ describe('SprintEngine', () => {
beforeEach(() => {
events = [];
engine = new SprintEngine({
onEvent: (name, data) => events.push({ name, data })
onEvent: (name, data) => events.push({ name, data }),
});
});
@@ -45,7 +45,7 @@ describe('SprintEngine', () => {
test('tick auto-stops when time expires and emits sprint:complete', () => {
engine.start(1, 100); // 1 minute
engine.tick(60 * 1000 + 1); // just past
expect(events.some(e => e.name === 'sprint:complete')).toBe(true);
expect(events.some((e) => e.name === 'sprint:complete')).toBe(true);
expect(engine.isActive()).toBe(false);
});
+11 -7
View File
@@ -35,7 +35,7 @@ describe('Utility Functions', () => {
return {
command: parts[0],
args: parts.slice(1)
args: parts.slice(1),
};
}
@@ -58,7 +58,9 @@ describe('Utility Functions', () => {
});
test('should handle multiple options', () => {
const result = parseCommand('pandoc input.md --pdf-engine=xelatex -V geometry:margin=1in -o output.pdf');
const result = parseCommand(
'pandoc input.md --pdf-engine=xelatex -V geometry:margin=1in -o output.pdf'
);
expect(result.command).toBe('pandoc');
expect(result.args).toContain('--pdf-engine=xelatex');
expect(result.args).toContain('-V');
@@ -75,11 +77,13 @@ describe('Utility Functions', () => {
// This function converts hex colors to RGB
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255
} : null;
return result
? {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255,
}
: null;
}
test('should convert black hex to RGB', () => {